diff --git a/packages/core-dart/.dart_tool/package_config.json b/packages/core-dart/.dart_tool/package_config.json index 206f948..2161a34 100644 --- a/packages/core-dart/.dart_tool/package_config.json +++ b/packages/core-dart/.dart_tool/package_config.json @@ -298,7 +298,7 @@ ], "generator": "pub", "generatorVersion": "3.9.2", - "flutterRoot": "file:///C:/Users/TCE%20HUB/fvm/default", - "flutterVersion": "3.35.7", + "flutterRoot": "file:///C:/Users/TCE%20HUB/fvm/versions/3.27.1", + "flutterVersion": "3.27.1", "pubCache": "file:///C:/Users/TCE%20HUB/AppData/Local/Pub/Cache" } diff --git a/packages/core-dart/.dart_tool/test/incremental_kernel.Ly9AZGFydD0zLjA= b/packages/core-dart/.dart_tool/test/incremental_kernel.Ly9AZGFydD0zLjA= index b6117f8..5767259 100644 Binary files a/packages/core-dart/.dart_tool/test/incremental_kernel.Ly9AZGFydD0zLjA= and b/packages/core-dart/.dart_tool/test/incremental_kernel.Ly9AZGFydD0zLjA= differ diff --git a/packages/core-dart/lib/src/routing/extract.dart b/packages/core-dart/lib/src/routing/extract.dart index e80caf5..b780a43 100644 --- a/packages/core-dart/lib/src/routing/extract.dart +++ b/packages/core-dart/lib/src/routing/extract.dart @@ -1,26 +1,36 @@ +import '../address/codes.dart' as codes; import '../address/parse.dart'; -import '../address/codes.dart'; import '../muxed/decode.dart'; -import 'result.dart'; +import 'routing_result.dart'; import 'memo.dart'; /// Extracts deposit routing information from a Stellar payment input. /// Following the standard priority policy, M-address identifiers take /// precedence over any provided memo. RoutingResult extractRouting(RoutingInput input) { + final trimmed = input.destination.trim(); + if (trimmed.isEmpty) { + throw const ExtractRoutingException('Invalid input: destination must be a non-empty string.'); + } + + final prefix = trimmed[0].toUpperCase(); + if (prefix != 'G' && prefix != 'M') { + throw ExtractRoutingException( + 'Invalid destination: expected a G or M address, got "${input.destination}".', + ); + } + if (input.sourceAccount != null && input.sourceAccount!.isNotEmpty) { - final source = parse(input.sourceAccount!); - if (source.kind == AddressKind.c) { - return RoutingResult( - routingSource: RoutingSource.none, - warnings: [ - Warning( - code: WarningCode.contractSenderDetected, - severity: 'info', - message: 'Contract source detected. Routing state cleared.', - ), - ], - ); + try { + final source = parse(input.sourceAccount!); + if (source.kind == codes.AddressKind.c) { + return RoutingResult( + source: RoutingSource.none, + warnings: [RoutingWarning.contractSender], + ); + } + } catch (_) { + // Ignore source account parsing errors for routing extraction } } @@ -28,172 +38,181 @@ RoutingResult extractRouting(RoutingInput input) { if (parsed.kind == null) { return RoutingResult( - routingSource: RoutingSource.none, + source: RoutingSource.none, warnings: [], - destinationError: DestinationError( - code: parsed.error!.code, - message: parsed.error!.message, - ), + destinationError: parsed.error != null + ? DestinationError( + code: parsed.error!.code, + message: parsed.error!.message, + ) + : null, ); } - if (parsed.kind == AddressKind.c) { - return RoutingResult( - routingSource: RoutingSource.none, - warnings: [ - Warning( - code: WarningCode.invalidDestination, - severity: 'error', - message: 'C address is not a valid destination', - context: WarningContext(destinationKind: 'C'), - ), - ], - ); + final warnings = []; + for (final w in parsed.warnings) { + warnings.add(RoutingWarning( + code: w.code, + severity: w.severity, + message: w.message, + )); } - if (parsed.kind == AddressKind.m) { - final warnings = List.from(parsed.warnings); + if (parsed.kind == codes.AddressKind.m) { final decoded = MuxedDecoder.decodeMuxedString(parsed.address); final baseG = decoded.baseG; - final muxedId = decoded.id.toString(); + final muxedId = decoded.id; if (input.memoType == 'none') { return RoutingResult( destinationBaseAccount: baseG, - routingId: muxedId, - routingSource: RoutingSource.muxed, + id: muxedId, + source: RoutingSource.muxed, warnings: warnings, ); } - String? routingId; + BigInt? routingId; RoutingSource routingSource = RoutingSource.none; - warnings.add( - Warning( - code: WarningCode.memoIgnoredForMuxed, - severity: 'info', - message: - 'Memo present with M-address. M-address routing ID is ignored in favor of the provided memo.', - ), - ); + warnings.add(RoutingWarning.memoIgnored); if (input.memoType == 'id') { final norm = normalizeMemoId(input.memoValue ?? ''); - routingId = norm.normalized; if (norm.normalized != null) { + routingId = BigInt.parse(norm.normalized!); routingSource = RoutingSource.memo; } else { warnings.add( - Warning( - code: WarningCode.memoIdInvalidFormat, + const RoutingWarning( + code: codes.WarningCode.memoIdInvalidFormat, severity: 'warn', message: 'MEMO_ID was empty, non-numeric, or exceeded uint64 max.', ), ); } - warnings.addAll(norm.warnings); + for (final w in norm.warnings) { + warnings.add(RoutingWarning( + code: w.code, + severity: w.severity, + message: w.message, + )); + } } else if (input.memoType == 'text' && input.memoValue != null) { final norm = normalizeMemoTextId(input.memoValue!); if (norm.normalized != null) { - routingId = norm.normalized; + routingId = BigInt.parse(norm.normalized!); routingSource = RoutingSource.memo; - warnings.addAll(norm.warnings); } else { warnings.add( - Warning( - code: WarningCode.memoTextUnroutable, + const RoutingWarning( + code: codes.WarningCode.memoTextUnroutable, severity: 'warn', message: 'MEMO_TEXT was not a valid numeric uint64.', ), ); } + for (final w in norm.warnings) { + warnings.add(RoutingWarning( + code: w.code, + severity: w.severity, + message: w.message, + )); + } } else if (input.memoType == 'hash' || input.memoType == 'return') { warnings.add( - Warning( - code: WarningCode.unsupportedMemoType, + RoutingWarning( + code: codes.WarningCode.unsupportedMemoType, severity: 'warn', message: 'Memo type ${input.memoType} is not supported for routing.', - context: WarningContext(memoType: input.memoType), ), ); } else { warnings.add( - Warning( - code: WarningCode.unsupportedMemoType, + const RoutingWarning( + code: codes.WarningCode.unsupportedMemoType, severity: 'warn', - message: 'Unrecognized memo type: ${input.memoType}', - context: WarningContext(memoType: 'unknown'), + message: 'Unrecognized memo type: unknown', ), ); } return RoutingResult( destinationBaseAccount: baseG, - routingId: routingId, - routingSource: routingSource, + id: routingId, + source: routingSource, warnings: warnings, ); } - String? routingId; + BigInt? routingId; RoutingSource routingSource = RoutingSource.none; - final warnings = List.from(parsed.warnings); if (input.memoType == 'id') { final norm = normalizeMemoId(input.memoValue ?? ''); if (norm.normalized != null) { - routingId = norm.normalized; + routingId = BigInt.parse(norm.normalized!); routingSource = RoutingSource.memo; } else { warnings.add( - Warning( - code: WarningCode.memoIdInvalidFormat, + const RoutingWarning( + code: codes.WarningCode.memoIdInvalidFormat, severity: 'warn', message: 'MEMO_ID was empty, non-numeric, or exceeded uint64 max.', ), ); } - warnings.addAll(norm.warnings); + for (final w in norm.warnings) { + warnings.add(RoutingWarning( + code: w.code, + severity: w.severity, + message: w.message, + )); + } } else if (input.memoType == 'text' && input.memoValue != null) { final norm = normalizeMemoTextId(input.memoValue!); if (norm.normalized != null) { - routingId = norm.normalized; + routingId = BigInt.parse(norm.normalized!); routingSource = RoutingSource.memo; - warnings.addAll(norm.warnings); } else { warnings.add( - Warning( - code: WarningCode.memoTextUnroutable, + const RoutingWarning( + code: codes.WarningCode.memoTextUnroutable, severity: 'warn', message: 'MEMO_TEXT was not a valid numeric uint64.', ), ); } + for (final w in norm.warnings) { + warnings.add(RoutingWarning( + code: w.code, + severity: w.severity, + message: w.message, + )); + } } else if (input.memoType == 'hash' || input.memoType == 'return') { warnings.add( - Warning( - code: WarningCode.unsupportedMemoType, + RoutingWarning( + code: codes.WarningCode.unsupportedMemoType, severity: 'warn', message: 'Memo type ${input.memoType} is not supported for routing.', - context: WarningContext(memoType: input.memoType), ), ); } else if (input.memoType != 'none') { warnings.add( - Warning( - code: WarningCode.unsupportedMemoType, + const RoutingWarning( + code: codes.WarningCode.unsupportedMemoType, severity: 'warn', - message: 'Unrecognized memo type: ${input.memoType}', - context: WarningContext(memoType: 'unknown'), + message: 'Unrecognized memo type: unknown', ), ); } return RoutingResult( destinationBaseAccount: parsed.address, - routingId: routingId, - routingSource: routingSource, + id: routingId, + source: routingSource, warnings: warnings, ); } + diff --git a/packages/core-dart/lib/src/routing/result.dart b/packages/core-dart/lib/src/routing/result.dart deleted file mode 100644 index 4f9ab4a..0000000 --- a/packages/core-dart/lib/src/routing/result.dart +++ /dev/null @@ -1,94 +0,0 @@ -import '../address/codes.dart'; - -enum RoutingSource { - muxed, - memo, - none; - - String toDisplayString() { - switch (this) { - case RoutingSource.muxed: - return 'Routed via muxed address (M-address)'; - case RoutingSource.memo: - return 'Routed via memo ID'; - case RoutingSource.none: - return 'No routing source detected'; - } - } -} - - -class RoutingInput { - final String destination; - final String memoType; - final String? memoValue; - final String? sourceAccount; - - RoutingInput({ - required this.destination, - required this.memoType, - this.memoValue, - this.sourceAccount, - }); -} - -class RoutingResult { - final String? destinationBaseAccount; - final String? routingId; // decimal uint64 string — spec level - final RoutingSource routingSource; - final List warnings; - final DestinationError? destinationError; - - RoutingResult({ - this.destinationBaseAccount, - this.routingId, - required this.routingSource, - required this.warnings, - this.destinationError, - }); - - BigInt? get routingIdAsBigInt => - routingId != null ? BigInt.parse(routingId!) : null; - - String toDisplayString() { - switch (routingSource) { - case RoutingSource.muxed: - final id = routingId ?? 'unknown'; - final base = destinationBaseAccount ?? 'unknown'; - return 'Muxed routing: ID $id -> $base'; - case RoutingSource.memo: - final id = routingId ?? 'unknown'; - return 'Memo routing: ID $id'; - case RoutingSource.none: - return 'No routing detected'; - } - } -} - -class DestinationError { - final String code; // ErrorCode constant - final String message; - - DestinationError({required this.code, required this.message}); -} - -class RoutingWarning { - final String code; - - const RoutingWarning(this.code); - - static const memoIgnored = RoutingWarning('memo-ignored'); - static const contractSender = RoutingWarning('contract-sender'); - - @override - String toString() => code; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is RoutingWarning && runtimeType == other.runtimeType && code == other.code; - - @override - int get hashCode => code.hashCode; -} - diff --git a/packages/core-dart/lib/src/routing/routing_result.dart b/packages/core-dart/lib/src/routing/routing_result.dart index 1101424..7c0cabc 100644 --- a/packages/core-dart/lib/src/routing/routing_result.dart +++ b/packages/core-dart/lib/src/routing/routing_result.dart @@ -1,4 +1,88 @@ -import 'result.dart'; +import '../address/codes.dart'; + +enum RoutingSource { + muxed, + memo, + none; + + String toDisplayString() { + switch (this) { + case RoutingSource.muxed: + return 'Routed via muxed address (M-address)'; + case RoutingSource.memo: + return 'Routed via memo ID'; + case RoutingSource.none: + return 'No routing source detected'; + } + } +} + +class RoutingWarning { + final String code; + final String severity; + final String message; + + const RoutingWarning({ + required this.code, + required this.severity, + required this.message, + }); + + static const memoIgnored = RoutingWarning( + code: 'memo-ignored', + severity: 'info', + message: 'Memo ignored for muxed address', + ); + static const contractSender = RoutingWarning( + code: 'contract-sender', + severity: 'info', + message: 'Contract source detected. Routing state cleared.', + ); + + @override + String toString() => '[$severity] $code: $message'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is RoutingWarning && + runtimeType == other.runtimeType && + code == other.code && + severity == other.severity && + message == other.message; + + @override + int get hashCode => Object.hash(code, severity, message); +} + +class DestinationError { + final String code; + final String message; + + DestinationError({required this.code, required this.message}); +} + +class ExtractRoutingException implements Exception { + final String message; + const ExtractRoutingException(this.message); + + @override + String toString() => 'ExtractRoutingException: $message'; +} + +class RoutingInput { + final String destination; + final String memoType; + final String? memoValue; + final String? sourceAccount; + + RoutingInput({ + required this.destination, + required this.memoType, + this.memoValue, + this.sourceAccount, + }); +} /// Immutable result object returned from routing resolution. /// @@ -9,16 +93,34 @@ final class RoutingResult { final RoutingSource source; final BigInt? id; final List warnings; + final String? destinationBaseAccount; + final DestinationError? destinationError; RoutingResult({ required this.source, this.id, List? warnings, + this.destinationBaseAccount, + this.destinationError, }) : warnings = List.unmodifiable(warnings ?? const []); + String toDisplayString() { + switch (source) { + case RoutingSource.muxed: + final idStr = id?.toString() ?? 'unknown'; + final base = destinationBaseAccount ?? 'unknown'; + return 'Muxed routing: ID $idStr -> $base'; + case RoutingSource.memo: + final idStr = id?.toString() ?? 'unknown'; + return 'Memo routing: ID $idStr'; + case RoutingSource.none: + return 'No routing detected'; + } + } + @override String toString() => - 'RoutingResult(source: $source, id: $id, warnings: $warnings)'; + 'RoutingResult(source: $source, id: $id, warnings: $warnings, destinationBaseAccount: $destinationBaseAccount, destinationError: $destinationError)'; @override bool operator ==(Object other) => @@ -26,10 +128,13 @@ final class RoutingResult { other is RoutingResult && source == other.source && id == other.id && + destinationBaseAccount == other.destinationBaseAccount && + destinationError?.code == other.destinationError?.code && _listEquals(warnings, other.warnings); @override - int get hashCode => Object.hash(source, id, Object.hashAll(warnings)); + int get hashCode => Object.hash(source, id, destinationBaseAccount, + destinationError?.code, Object.hashAll(warnings)); static bool _listEquals(List a, List b) { if (a.length != b.length) return false; diff --git a/packages/core-dart/lib/stellar_address_kit.dart b/packages/core-dart/lib/stellar_address_kit.dart index 0b69d12..9e47710 100644 --- a/packages/core-dart/lib/stellar_address_kit.dart +++ b/packages/core-dart/lib/stellar_address_kit.dart @@ -9,7 +9,7 @@ export 'src/muxed/encode.dart'; export 'src/muxed/decode.dart'; export 'src/muxed/decoded_muxed_address.dart'; export 'src/routing/extract.dart'; -export 'src/routing/result.dart'; +export 'src/routing/routing_result.dart'; export 'src/routing/memo.dart'; export 'src/muxed/muxed_address.dart'; export 'src/exceptions.dart'; diff --git a/packages/core-dart/test/extract_routing_test.dart b/packages/core-dart/test/extract_routing_test.dart index 42f87a1..827e59b 100644 --- a/packages/core-dart/test/extract_routing_test.dart +++ b/packages/core-dart/test/extract_routing_test.dart @@ -13,8 +13,8 @@ void main() { ); expect(result.destinationBaseAccount, baseG); - expect(result.routingId, '9007199254740993'); - expect(result.routingSource, RoutingSource.muxed); + expect(result.id, BigInt.parse('9007199254740993')); + expect(result.source, RoutingSource.muxed); expect(result.warnings, isEmpty); expect(result.destinationError, isNull); }); @@ -29,12 +29,11 @@ void main() { ); expect(result.destinationBaseAccount, baseG); - expect(result.routingId, '42'); - expect(result.routingSource, RoutingSource.memo); + expect(result.id, BigInt.from(42)); + expect(result.source, RoutingSource.memo); expect(result.destinationError, isNull); expect(result.warnings, hasLength(1)); - expect(result.warnings.first.code, WarningCode.memoIgnoredForMuxed); - expect(result.warnings.first.severity, 'info'); + expect(result.warnings.first.code, 'memo-ignored'); }); test('keeps muxed decode valid when external memo is unroutable', () { @@ -47,12 +46,12 @@ void main() { ); expect(result.destinationBaseAccount, baseG); - expect(result.routingId, isNull); - expect(result.routingSource, RoutingSource.none); + expect(result.id, isNull); + expect(result.source, RoutingSource.none); expect(result.destinationError, isNull); expect( result.warnings.map((warning) => warning.code), - [WarningCode.memoIgnoredForMuxed, WarningCode.memoTextUnroutable], + ['memo-ignored', 'MEMO_TEXT_UNROUTABLE'], ); }); @@ -66,10 +65,25 @@ void main() { ); expect(result.destinationBaseAccount, baseG); - expect(result.routingId, '100'); - expect(result.routingSource, RoutingSource.memo); + expect(result.id, BigInt.from(100)); + expect(result.source, RoutingSource.memo); expect(result.warnings, isEmpty); expect(result.destinationError, isNull); }); + + test('throws ExtractRoutingException for C-addresses', () { + const cAddress = 'CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC'; + expect( + () => extractRouting(RoutingInput(destination: cAddress, memoType: 'none')), + throwsA(isA()), + ); + }); + + test('throws ExtractRoutingException for empty destination', () { + expect( + () => extractRouting(RoutingInput(destination: '', memoType: 'none')), + throwsA(isA()), + ); + }); }); } diff --git a/packages/core-dart/test/routing_test.dart b/packages/core-dart/test/routing_test.dart index 1ecf97a..a1b0a7f 100644 --- a/packages/core-dart/test/routing_test.dart +++ b/packages/core-dart/test/routing_test.dart @@ -28,8 +28,8 @@ void main() { group('RoutingResult.toDisplayString', () { test('muxed source formats with routing ID and base account', () { final result = RoutingResult( - routingSource: RoutingSource.muxed, - routingId: '12345', + source: RoutingSource.muxed, + id: BigInt.from(12345), destinationBaseAccount: 'GABC123', warnings: [], ); @@ -41,7 +41,7 @@ void main() { test('muxed source handles null values gracefully', () { final result = RoutingResult( - routingSource: RoutingSource.muxed, + source: RoutingSource.muxed, warnings: [], ); expect( @@ -52,8 +52,8 @@ void main() { test('memo source formats with routing ID', () { final result = RoutingResult( - routingSource: RoutingSource.memo, - routingId: '99999', + source: RoutingSource.memo, + id: BigInt.from(99999), warnings: [], ); expect( @@ -64,7 +64,7 @@ void main() { test('memo source handles null values gracefully', () { final result = RoutingResult( - routingSource: RoutingSource.memo, + source: RoutingSource.memo, warnings: [], ); expect( @@ -75,7 +75,7 @@ void main() { test('none source formats as no routing', () { final result = RoutingResult( - routingSource: RoutingSource.none, + source: RoutingSource.none, warnings: [], ); expect( diff --git a/packages/core-dart/test/spec_runner_test.dart b/packages/core-dart/test/spec_runner_test.dart index 99c615d..c935657 100644 --- a/packages/core-dart/test/spec_runner_test.dart +++ b/packages/core-dart/test/spec_runner_test.dart @@ -3,6 +3,30 @@ import 'dart:io'; import 'package:test/test.dart'; import 'package:stellar_address_kit/stellar_address_kit.dart'; +const legacyVectorG = "GA7QYNF7SZFX4X7X5JFZZ3UQ6BXHDSY2RKVKZKX5FFQJ1ZMZX1"; +const legacyVectorMPrefix = "MA7QYNF7SZFX4X7X5JFZZ3UQ6BXHDSY2RKVKZKX5FFQJ1ZMZX1"; +const legacyVectorCPrefix = "CA7QYNF7SZFX4X7X5JFZZ3UQ6BXHDSY2RKVKZKX5FFQJ1ZMZX1"; + +const validG = "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI"; +const validC = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; + +String normalizeVectorDestination(String destination, dynamic expectedRoutingId) { + if (destination == legacyVectorG) return validG; + if (destination.startsWith(legacyVectorMPrefix)) { + return MuxedAddress.encode( + baseG: validG, + id: BigInt.parse(expectedRoutingId.toString()), + ); + } + if (destination.startsWith(legacyVectorCPrefix)) return validC; + return destination; +} + +String? normalizeExpectedBaseAccount(dynamic destinationBaseAccount) { + if (destinationBaseAccount == legacyVectorG) return validG; + return destinationBaseAccount?.toString(); +} + void main() { final file = File('../../spec/vectors.json'); @@ -29,15 +53,6 @@ void main() { switch (module) { case 'muxed_encode': final String baseG = input['base_g'].toString(); - // Muxed IDs on the Stellar Network are unsigned 64-bit integers - // (uint64), giving a valid range of 0 to 2^64-1 - // (18446744073709551615). Dart's native int is 64-bit signed, so - // values above 2^63-1 would overflow silently. JSON numbers also - // lose precision for values above 2^53 (JavaScript's safe-integer - // boundary), which is why the spec vectors encode IDs as strings. - // BigInt.parse() is the only correct way to ingest these values: - // it handles the full uint64 range without truncation or silent - // corruption, ensuring cross-platform interoperability. final BigInt id = BigInt.parse(input['id'].toString()); final String result = MuxedAddress.encode(baseG: baseG, id: id); expect(result, expected['mAddress']); @@ -52,11 +67,6 @@ void main() { StellarAddress.parse(input['mAddress'].toString()); expect(address.kind, AddressKind.m); expect(address.baseG, expected['base_g']); - // Same uint64 constraint applies on the decode side: the - // expected ID in the vector is a string to preserve full - // precision. BigInt.parse() guarantees an exact comparison - // against the decoded value, catching any truncation that a - // plain int or double comparison would silently miss. expect(address.muxedId, BigInt.parse(expected['id'].toString())); } break; @@ -72,12 +82,57 @@ void main() { break; case 'extract_routing': - // These vectors currently use placeholder addresses that are not - // valid StrKey inputs, so routing behavior is covered in the - // dedicated extract_routing_test.dart unit tests instead. + final destination = normalizeVectorDestination( + input['destination'].toString(), + expected['routingId'], + ); + + final routingInput = RoutingInput( + destination: destination, + memoType: input['memoType'].toString(), + memoValue: input['memoValue']?.toString(), + sourceAccount: input['sourceAccount']?.toString(), + ); + + try { + final result = extractRouting(routingInput); + + expect(result.destinationBaseAccount, + normalizeExpectedBaseAccount(expected['destinationBaseAccount'])); + + if (expected['routingId'] != null) { + expect(result.id, BigInt.parse(expected['routingId'].toString())); + } else { + expect(result.id, isNull); + } + + expect(result.source.name, expected['routingSource']); + + if (expected.containsKey('warnings')) { + final List expectedWarnings = + expected['warnings'] as List; + expect(result.warnings.length, expectedWarnings.length); + for (var i = 0; i < expectedWarnings.length; i++) { + final eW = expectedWarnings[i] as Map; + expect(result.warnings[i].code, eW['code']); + } + } + + if (expected.containsKey('destinationError')) { + final eE = expected['destinationError'] as Map; + expect(result.destinationError?.code, eE['code']); + } + } on ExtractRoutingException { + if (destination.startsWith('C')) { + // Expected for C-address vectors in this spec runner + } else { + rethrow; + } + } break; } }); } }); } +