diff --git a/examples/flutter-demo/lib/app.dart b/examples/flutter-demo/lib/app.dart index b7e5e0f0..066e1b14 100644 --- a/examples/flutter-demo/lib/app.dart +++ b/examples/flutter-demo/lib/app.dart @@ -4,8 +4,10 @@ import 'core/theme/app_theme.dart'; import 'features/receive/domain/usecases/generate_deposit_instruction.dart'; import 'features/receive/presentation/bloc/receive_bloc.dart'; import 'features/analyze/domain/usecases/analyze_address.dart'; -import 'features/analyze/presentation/bloc/analyze_bloc.dart'; -import 'features/home/presentation/home_screen.dart'; +import 'package:stellar_address_kit_demo/features/analyze/presentation/bloc/analyze_bloc.dart'; +import 'package:stellar_address_kit_demo/features/safe_bloc.dart'; +import 'package:stellar_address_kit_demo/features/unsafe_bloc.dart'; +import 'package:stellar_address_kit_demo/features/home/presentation/home_screen.dart'; class App extends StatelessWidget { const App({super.key}); @@ -24,6 +26,12 @@ class App extends StatelessWidget { analyzeUseCase: AnalyzeAddress(), ), ), + BlocProvider( + create: (context) => SafeBloc(), + ), + BlocProvider( + create: (context) => UnsafeBloc(), + ), ], child: MaterialApp( title: 'Stellar Address Kit Demo', diff --git a/examples/flutter-demo/lib/features/home/presentation/home_screen.dart b/examples/flutter-demo/lib/features/home/presentation/home_screen.dart index 462cddca..540c8949 100644 --- a/examples/flutter-demo/lib/features/home/presentation/home_screen.dart +++ b/examples/flutter-demo/lib/features/home/presentation/home_screen.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import '../../receive/presentation/widgets/receive_panel.dart'; import '../../analyze/presentation/widgets/analyze_panel.dart'; +import 'package:stellar_address_kit_demo/features/safe_panel.dart'; +import 'package:stellar_address_kit_demo/features/unsafe_panel.dart'; class HomeScreen extends StatelessWidget { const HomeScreen({super.key}); @@ -17,37 +19,45 @@ class HomeScreen extends StatelessWidget { if (constraints.maxWidth > 900) { return const Row( children: [ - Expanded(child: ReceivePanel()), + Expanded(child: UnsafePanel()), VerticalDivider(width: 1), Expanded(child: AnalyzePanel()), + VerticalDivider(width: 1), + Expanded(child: SafePanel()), ], ); } else if (constraints.maxWidth > 600) { return const SingleChildScrollView( child: Column( children: [ - ReceivePanel(), + UnsafePanel(), Divider(height: 1), AnalyzePanel(), + Divider(height: 1), + SafePanel(), ], ), ); } else { return const DefaultTabController( - length: 2, + length: 4, child: Column( children: [ TabBar( tabs: [ - Tab(text: 'Receive', icon: Icon(Icons.download)), + Tab(text: 'Unsafe', icon: Icon(Icons.warning)), Tab(text: 'Analyze', icon: Icon(Icons.search)), + Tab(text: 'Safe', icon: Icon(Icons.security)), + Tab(text: 'Receive', icon: Icon(Icons.download)), ], ), Expanded( child: TabBarView( children: [ - ReceivePanel(), + UnsafePanel(), AnalyzePanel(), + SafePanel(), + ReceivePanel(), ], ), ), diff --git a/examples/flutter-demo/lib/features/safe_bloc.dart b/examples/flutter-demo/lib/features/safe_bloc.dart new file mode 100644 index 00000000..207da697 --- /dev/null +++ b/examples/flutter-demo/lib/features/safe_bloc.dart @@ -0,0 +1,62 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:stellar_address_kit/stellar_address_kit.dart'; + +abstract class SafeEvent extends Equatable { + const SafeEvent(); + @override + List get props => []; +} + +class SafeAddressChanged extends SafeEvent { + final String address; + const SafeAddressChanged(this.address); + @override + List get props => [address]; +} + +abstract class SafeState extends Equatable { + const SafeState(); + @override + List get props => []; +} + +class SafeInitial extends SafeState {} + +class SafeDecoded extends SafeState { + final BigInt id; + const SafeDecoded(this.id); + @override + List get props => [id]; +} + +class SafeError extends SafeState { + final String error; + const SafeError(this.error); + @override + List get props => [error]; +} + +class SafeBloc extends Bloc { + SafeBloc() : super(SafeInitial()) { + on(_onAddressChanged); + } + + void _onAddressChanged(SafeAddressChanged event, Emitter emit) { + if (event.address.isEmpty) { + emit(SafeInitial()); + return; + } + + try { + final parsed = StellarAddress.parse(event.address); + if (parsed.muxedId != null) { + emit(SafeDecoded(parsed.muxedId!)); + } else { + emit(const SafeError('Not a muxed address')); + } + } catch (e) { + emit(SafeError(e.toString())); + } + } +} diff --git a/examples/flutter-demo/lib/features/safe_panel.dart b/examples/flutter-demo/lib/features/safe_panel.dart new file mode 100644 index 00000000..8a854562 --- /dev/null +++ b/examples/flutter-demo/lib/features/safe_panel.dart @@ -0,0 +1,151 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:stellar_address_kit_demo/features/safe_bloc.dart'; +import 'package:stellar_address_kit_demo/features/analyze/presentation/bloc/analyze_bloc.dart'; + +class SafePanel extends StatelessWidget { + const SafePanel({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.security, color: Colors.green), + const SizedBox(width: 8), + Text( + 'Safe Decode (BigInt)', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + color: Colors.green[800], + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 8), + const Text( + 'This panel uses the native BigInt path to ensure 100% precision on all platforms.', + style: TextStyle(color: Colors.grey), + ), + const SizedBox(height: 24), + BlocBuilder( + builder: (context, safeState) { + if (safeState is SafeDecoded) { + return BlocBuilder( + builder: (context, analyzeState) { + BigInt? otherId; + if (analyzeState is AnalyzeSuccess) { + otherId = analyzeState.analysis.routingId; + } + + final isCorrupted = otherId != null && otherId != safeState.id; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildIdCard(safeState.id, isCorrupted, otherId), + const SizedBox(height: 24), + if (isCorrupted) _buildCorruptionWarning(safeState.id, otherId!), + ], + ); + }, + ); + } else if (safeState is SafeError) { + return Center(child: Text(safeState.error, style: const TextStyle(color: Colors.red))); + } + return const Center(child: Text('Waiting for input...')); + }, + ), + ], + ), + ); + } + + Widget _buildIdCard(BigInt id, bool isCorrupted, BigInt? otherId) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isCorrupted ? Colors.orange : Colors.green.withOpacity(0.3), + width: 2, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'EXTRACTED ID', + style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Colors.grey), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.green, + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'CORRECT', + style: TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold), + ), + ), + ], + ), + const SizedBox(height: 12), + SelectableText( + id.toString(), + style: const TextStyle( + fontSize: 24, + fontFamily: 'JetBrains Mono', + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + ], + ), + ); + } + + Widget _buildCorruptionWarning(BigInt correctId, BigInt corruptedId) { + final diff = (correctId - corruptedId).abs(); + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.orange.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.orange.withOpacity(0.3)), + ), + child: Row( + children: [ + const Icon(Icons.warning_amber_rounded, color: Colors.orange), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'PRECISION LOSS DETECTED', + style: TextStyle(fontWeight: FontWeight.bold, color: Colors.orange, fontSize: 12), + ), + const SizedBox(height: 4), + Text( + 'The left panel shows a corrupted value. Difference: $diff', + style: const TextStyle(fontSize: 12), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/examples/flutter-demo/lib/features/unsafe_bloc.dart b/examples/flutter-demo/lib/features/unsafe_bloc.dart new file mode 100644 index 00000000..b57c4190 --- /dev/null +++ b/examples/flutter-demo/lib/features/unsafe_bloc.dart @@ -0,0 +1,67 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:stellar_address_kit/stellar_address_kit.dart'; + +abstract class UnsafeEvent extends Equatable { + const UnsafeEvent(); + @override + List get props => []; +} + +class UnsafeAddressChanged extends UnsafeEvent { + final String address; + const UnsafeAddressChanged(this.address); + @override + List get props => [address]; +} + +abstract class UnsafeState extends Equatable { + const UnsafeState(); + @override + List get props => []; +} + +class UnsafeInitial extends UnsafeState {} + +class UnsafeDecoded extends UnsafeState { + final int id; + final bool corrupted; + const UnsafeDecoded(this.id, this.corrupted); + @override + List get props => [id, corrupted]; +} + +class UnsafeError extends UnsafeState { + final String error; + const UnsafeError(this.error); + @override + List get props => [error]; +} + +class UnsafeBloc extends Bloc { + UnsafeBloc() : super(UnsafeInitial()) { + on(_onAddressChanged); + } + + void _onAddressChanged(UnsafeAddressChanged event, Emitter emit) { + if (event.address.isEmpty) { + emit(UnsafeInitial()); + return; + } + + try { + final parsed = StellarAddress.parse(event.address); + if (parsed.muxedId != null) { + final realId = parsed.muxedId!; + final idString = realId.toString(); + final unsafeId = int.parse(idString); + final isCorrupted = BigInt.from(unsafeId) != realId; + emit(UnsafeDecoded(unsafeId, isCorrupted)); + } else { + emit(const UnsafeError('Not a muxed address')); + } + } catch (e) { + emit(UnsafeError(e.toString())); + } + } +} diff --git a/examples/flutter-demo/lib/features/unsafe_panel.dart b/examples/flutter-demo/lib/features/unsafe_panel.dart new file mode 100644 index 00000000..39d3cdd1 --- /dev/null +++ b/examples/flutter-demo/lib/features/unsafe_panel.dart @@ -0,0 +1,181 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:stellar_address_kit_demo/features/unsafe_bloc.dart'; +import 'package:stellar_address_kit_demo/features/analyze/presentation/bloc/analyze_bloc.dart'; +import 'package:stellar_address_kit_demo/features/safe_bloc.dart'; + +class UnsafePanel extends StatefulWidget { + const UnsafePanel({super.key}); + + @override + State createState() => _UnsafePanelState(); +} + +class _UnsafePanelState extends State { + final _addressController = TextEditingController( + text: 'MAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADAAAAAAAAAAAAB6AA', + ); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _onChanged()); + } + + void _onChanged() { + final address = _addressController.text; + context.read().add(UnsafeAddressChanged(address)); + context.read().add( + AnalyzeInputChanged( + address: address, + ), + ); + context.read().add(SafeAddressChanged(address)); + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(24.0), + color: Colors.red.withOpacity(0.05), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.warning_amber_rounded, color: Colors.red), + const SizedBox(width: 8), + Text( + 'Unsafe Decode (int)', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + color: Colors.red[800], + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 8), + const Text( + 'This panel uses standard int.parse(), which fails on large IDs when running on Flutter Web.', + style: TextStyle(color: Colors.grey), + ), + const SizedBox(height: 24), + TextField( + controller: _addressController, + onChanged: (_) => _onChanged(), + decoration: const InputDecoration( + labelText: 'Muxed Address', + border: OutlineInputBorder(), + fillColor: Colors.white, + filled: true, + ), + ), + const SizedBox(height: 24), + Expanded( + child: BlocBuilder( + builder: (context, state) { + if (state is UnsafeDecoded) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildIdCard(state.id, state.corrupted), + const SizedBox(height: 24), + _buildPlatformNote(), + ], + ); + } else if (state is UnsafeError) { + return Center(child: Text(state.error, style: const TextStyle(color: Colors.red))); + } + return const Center(child: Text('Enter address to decode')); + }, + ), + ), + ], + ), + ); + } + + Widget _buildIdCard(int id, bool corrupted) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: corrupted ? Colors.red : Colors.grey.withOpacity(0.3), + width: 2, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'EXTRACTED ID', + style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Colors.grey), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: corrupted ? Colors.red : Colors.green, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + corrupted ? 'CORRUPTED' : 'OK', + style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold), + ), + ), + ], + ), + const SizedBox(height: 12), + SelectableText( + id.toString(), + style: TextStyle( + fontSize: 24, + fontFamily: 'JetBrains Mono', + fontWeight: FontWeight.bold, + color: corrupted ? Colors.red : Colors.black87, + ), + ), + ], + ), + ); + } + + Widget _buildPlatformNote() { + final isWeb = kIsWeb; + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.blue.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'PLATFORM NOTE', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: Colors.blue), + ), + const SizedBox(height: 4), + Text( + isWeb + ? 'Running on Web: JavaScript numbers only have 53 bits of precision. This ID exceeds that limit and has been rounded.' + : 'Running on Native: 64-bit integers are supported natively, so this value is currently correct.', + style: const TextStyle(fontSize: 12), + ), + ], + ), + ); + } + + @override + void dispose() { + _addressController.dispose(); + super.dispose(); + } +} diff --git a/examples/go-exchange/contract-deposit-firewall/firewall/filter.go b/examples/go-exchange/contract-deposit-firewall/firewall/filter.go index 2d775b27..c4ef2a71 100644 --- a/examples/go-exchange/contract-deposit-firewall/firewall/filter.go +++ b/examples/go-exchange/contract-deposit-firewall/firewall/filter.go @@ -1,5 +1,10 @@ package firewall +import ( + "github.com/Boxkit-Labs/stellar-address-kit/packages/core-go/address" + "github.com/Boxkit-Labs/stellar-address-kit/packages/core-go/routing" +) + // Decision represents the action to take for a deposit. type Decision string @@ -10,7 +15,69 @@ const ( ) // FilterDeposit evaluates a deposit address and returns a routing decision. -func FilterDeposit(address string) Decision { - // Placeholder logic - return AutoCredit +// This function extracts routing information from the address and applies warning-to-decision mapping. +func FilterDeposit(addr string) Decision { + // Parse the address to get routing information + input := routing.RoutingInput{ + Destination: addr, + MemoType: "none", + MemoValue: "", + } + + result := routing.ExtractRouting(input) + return filterDepositFromResult(result) +} + +// filterDepositFromResult evaluates a RoutingResult and returns a deposit-processing decision. +// This is the core logic that maps warnings to decisions based on severity. +func filterDepositFromResult(result routing.RoutingResult) Decision { + // If no warnings and routing source is muxed or memo, return AutoCredit + if len(result.Warnings) == 0 && (result.RoutingSource == "muxed" || result.RoutingSource == "memo") { + return AutoCredit + } + + // Track the highest severity decision + highestDecision := AutoCredit + + for _, warning := range result.Warnings { + var decision Decision + + switch warning.Code { + case address.WarnContractSenderDetected: + // Contract sender poses security risk - quarantine immediately + decision = Quarantine + case address.WarnSmartAccountAmbiguousRouting: + // Smart account routing ambiguity requires manual review + decision = ManualReview + case address.WarnMemoIgnoredForMuxed: + // Memo ignored could indicate routing ambiguity - requires manual review + decision = ManualReview + case address.WarnMemoPresentWithMuxed: + // Conflicting routing information - requires manual review + decision = ManualReview + case address.WarnMuxedDestinationFromContract: + // Muxed destination from contract poses security risk - quarantine + decision = Quarantine + case address.WarnInvalidDestination: + // Invalid destination from contract - quarantine for security + decision = Quarantine + default: + // Unknown warnings default to manual review for safety + decision = ManualReview + } + + // Update highest severity decision (Quarantine > ManualReview > AutoCredit) + if decision == Quarantine { + return Quarantine // Highest severity, return immediately + } else if decision == ManualReview && highestDecision == AutoCredit { + highestDecision = ManualReview + } + } + + // If routing source is none, default to ManualReview for safety + if result.RoutingSource == "none" && highestDecision == AutoCredit { + return ManualReview + } + + return highestDecision } diff --git a/examples/go-exchange/contract-deposit-firewall/firewall/filter_test.go b/examples/go-exchange/contract-deposit-firewall/firewall/filter_test.go index d49864c5..efb9ddee 100644 --- a/examples/go-exchange/contract-deposit-firewall/firewall/filter_test.go +++ b/examples/go-exchange/contract-deposit-firewall/firewall/filter_test.go @@ -1,11 +1,187 @@ package firewall -import "testing" +import ( + "testing" -func TestFilterDeposit(t *testing.T) { - // Placeholder test - decision := FilterDeposit("G...") + "github.com/Boxkit-Labs/stellar-address-kit/packages/core-go/address" + "github.com/Boxkit-Labs/stellar-address-kit/packages/core-go/routing" +) + +// TestFilterDeposit_RoutingResult tests the core logic with RoutingResult input +func TestFilterDeposit_RoutingResult(t *testing.T) { + // Test AutoCredit with no warnings and muxed/memo source + result := routing.RoutingResult{ + RoutingSource: "muxed", + Warnings: []address.Warning{}, + } + decision := filterDepositFromResult(result) if decision != AutoCredit { - t.Errorf("expected AutoCredit, got %s", decision) + t.Errorf("expected AutoCredit for muxed source with no warnings, got %s", decision) + } + + result.RoutingSource = "memo" + decision = filterDepositFromResult(result) + if decision != AutoCredit { + t.Errorf("expected AutoCredit for memo source with no warnings, got %s", decision) + } +} + +// TestFilterDeposit_ManualReview_NoRoutingSource tests ManualReview for no routing source +func TestFilterDeposit_ManualReview_NoRoutingSource(t *testing.T) { + result := routing.RoutingResult{ + RoutingSource: "none", + Warnings: []address.Warning{}, + } + decision := filterDepositFromResult(result) + if decision != ManualReview { + t.Errorf("expected ManualReview for no routing source, got %s", decision) + } +} + +// TestFilterDeposit_Quarantine_ContractSender tests quarantine for contract sender +func TestFilterDeposit_Quarantine_ContractSender(t *testing.T) { + result := routing.RoutingResult{ + RoutingSource: "memo", + Warnings: []address.Warning{ + {Code: address.WarnContractSenderDetected}, + }, + } + decision := filterDepositFromResult(result) + if decision != Quarantine { + t.Errorf("expected Quarantine for contract sender warning, got %s", decision) + } +} + +// TestFilterDeposit_ManualReview_MemoIgnored tests manual review for memo ignored warning +func TestFilterDeposit_ManualReview_MemoIgnored(t *testing.T) { + result := routing.RoutingResult{ + RoutingSource: "muxed", + Warnings: []address.Warning{ + {Code: address.WarnMemoIgnoredForMuxed}, + }, + } + decision := filterDepositFromResult(result) + if decision != ManualReview { + t.Errorf("expected ManualReview for memo ignored warning, got %s", decision) + } +} + +// TestFilterDeposit_ManualReview_MemoPresentWithMuxed tests manual review for memo present with muxed +func TestFilterDeposit_ManualReview_MemoPresentWithMuxed(t *testing.T) { + result := routing.RoutingResult{ + RoutingSource: "muxed", + Warnings: []address.Warning{ + {Code: address.WarnMemoPresentWithMuxed}, + }, + } + decision := filterDepositFromResult(result) + if decision != ManualReview { + t.Errorf("expected ManualReview for memo present with muxed warning, got %s", decision) + } +} + +// TestFilterDeposit_Quarantine_InvalidDestination tests quarantine for invalid destination +func TestFilterDeposit_Quarantine_InvalidDestination(t *testing.T) { + result := routing.RoutingResult{ + RoutingSource: "none", + Warnings: []address.Warning{ + {Code: address.WarnInvalidDestination}, + }, + } + decision := filterDepositFromResult(result) + if decision != Quarantine { + t.Errorf("expected Quarantine for invalid destination warning, got %s", decision) + } +} + +// TestFilterDeposit_MultipleWarnings_HighestSeverity tests multiple warnings resolution +func TestFilterDeposit_MultipleWarnings_HighestSeverity(t *testing.T) { + // Test ManualReview + Quarantine = Quarantine + result := routing.RoutingResult{ + RoutingSource: "muxed", + Warnings: []address.Warning{ + {Code: address.WarnMemoIgnoredForMuxed}, + {Code: address.WarnContractSenderDetected}, + }, + } + decision := filterDepositFromResult(result) + if decision != Quarantine { + t.Errorf("expected Quarantine for multiple warnings with contract sender, got %s", decision) + } + + // Test AutoCredit + ManualReview = ManualReview + result = routing.RoutingResult{ + RoutingSource: "muxed", + Warnings: []address.Warning{ + {Code: address.WarnMemoIgnoredForMuxed}, + {Code: address.WarnMemoPresentWithMuxed}, + }, + } + decision = filterDepositFromResult(result) + if decision != ManualReview { + t.Errorf("expected ManualReview for multiple manual review warnings, got %s", decision) + } +} + +// TestFilterDeposit_ManualReview_SmartAccountAmbiguousRouting tests smart account ambiguous routing +func TestFilterDeposit_ManualReview_SmartAccountAmbiguousRouting(t *testing.T) { + result := routing.RoutingResult{ + RoutingSource: "memo", + Warnings: []address.Warning{ + {Code: address.WarnSmartAccountAmbiguousRouting}, + }, + } + decision := filterDepositFromResult(result) + if decision != ManualReview { + t.Errorf("expected ManualReview for smart account ambiguous routing warning, got %s", decision) + } +} + +// TestFilterDeposit_Quarantine_MuxedDestinationFromContract tests muxed destination from contract +func TestFilterDeposit_Quarantine_MuxedDestinationFromContract(t *testing.T) { + result := routing.RoutingResult{ + RoutingSource: "memo", + Warnings: []address.Warning{ + {Code: address.WarnMuxedDestinationFromContract}, + }, + } + decision := filterDepositFromResult(result) + if decision != Quarantine { + t.Errorf("expected Quarantine for muxed destination from contract warning, got %s", decision) + } +} + +// TestFilterDeposit_UnknownWarning tests unknown warning handling +func TestFilterDeposit_UnknownWarning(t *testing.T) { + result := routing.RoutingResult{ + RoutingSource: "memo", + Warnings: []address.Warning{ + {Code: "UNKNOWN_WARNING"}, + }, + } + decision := filterDepositFromResult(result) + if decision != ManualReview { + t.Errorf("expected ManualReview for unknown warning, got %s", decision) + } +} + +// TestFilterDeposit_StringInput tests the main FilterDeposit function with string input +func TestFilterDeposit_StringInput(t *testing.T) { + // Test with empty address + decision := FilterDeposit("") + if decision != ManualReview { + t.Errorf("expected ManualReview for empty address, got %s", decision) + } + + // Test with invalid address format + decision = FilterDeposit("INVALID_ADDRESS_FORMAT") + if decision != ManualReview { + t.Errorf("expected ManualReview for invalid address format, got %s", decision) + } + + // Test with valid G address (should return ManualReview due to no routing source) + decision = FilterDeposit("GDQIDLYENQVSG3VYRPBV3D5LKYQSQZEVJZWTZXKFSXL4UUG3G2J2MSVQ") + if decision != ManualReview { + t.Errorf("expected ManualReview for G address with no routing source, got %s", decision) } } diff --git a/examples/go-exchange/contract-deposit-firewall/go.mod b/examples/go-exchange/contract-deposit-firewall/go.mod index 9d3879f0..b09fe49a 100644 --- a/examples/go-exchange/contract-deposit-firewall/go.mod +++ b/examples/go-exchange/contract-deposit-firewall/go.mod @@ -2,4 +2,12 @@ module github.com/stellar-address-kit/examples/contract-deposit-firewall go 1.22 -require github.com/Boxkit-Labs/stellar-address-kit/packages/core-go v1.0.1 +replace github.com/Boxkit-Labs/stellar-address-kit/packages/core-go => ../../../packages/core-go + +require github.com/Boxkit-Labs/stellar-address-kit/packages/core-go v0.0.0-00010101000000-000000000000 + +require ( + github.com/pkg/errors v0.9.1 // indirect + github.com/stellar/go v0.0.0-20241220220012-089553bb324a // indirect + github.com/stellar/go-xdr v0.0.0-20231122183749-b53fb00bcac2 // indirect +) diff --git a/examples/go-exchange/contract-deposit-firewall/go.sum b/examples/go-exchange/contract-deposit-firewall/go.sum new file mode 100644 index 00000000..7f87a90e --- /dev/null +++ b/examples/go-exchange/contract-deposit-firewall/go.sum @@ -0,0 +1,14 @@ +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stellar/go v0.0.0-20241220220012-089553bb324a h1:DHSzxKJCTX1e0vtXe2pFqvDq2Pn6pENCr2xykWFciy4= +github.com/stellar/go v0.0.0-20241220220012-089553bb324a/go.mod h1:gY4J6cGScn4oPT7lDBurLUEf/ltVJfeMk8prEF6IJKo= +github.com/stellar/go-xdr v0.0.0-20231122183749-b53fb00bcac2 h1:OzCVd0SV5qE3ZcDeSFCmOWLZfEWZ3Oe8KtmSOYKEVWE= +github.com/stellar/go-xdr v0.0.0-20231122183749-b53fb00bcac2/go.mod h1:yoxyU/M8nl9LKeWIoBrbDPQ7Cy+4jxRcWcOayZ4BMps= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/examples/ts-backend/bigint-precision-auditor/README.md b/examples/ts-backend/bigint-precision-auditor/README.md index 17b29897..641ea4f5 100644 --- a/examples/ts-backend/bigint-precision-auditor/README.md +++ b/examples/ts-backend/bigint-precision-auditor/README.md @@ -4,9 +4,15 @@ This example demonstrates how the standard JavaScript Number() constructor silen ## Quick Start +```bash npm install +# Run with default high-ID address npx tsx src/main.ts +# Run with a specific muxed address +npx tsx src/main.ts M... +``` + ## Why This Matters Stellar muxed account IDs are uint64 values that frequently exceed the 53-bit precision limit of the JavaScript Number type. When these IDs are handled as standard numbers, the lower bits are silently truncated, leading to incorrect account routing and potential loss of funds. This library eliminates this entire class of bugs by enforcing BigInt usage for all numeric transformations. diff --git a/examples/ts-backend/bigint-precision-auditor/src/main.ts b/examples/ts-backend/bigint-precision-auditor/src/main.ts index f379ab3f..3bf71559 100644 --- a/examples/ts-backend/bigint-precision-auditor/src/main.ts +++ b/examples/ts-backend/bigint-precision-auditor/src/main.ts @@ -1,7 +1,65 @@ +import { encodeMuxed, decodeMuxed } from "stellar-address-kit"; + /** - * BigInt Precision Auditor - * This tool demonstrates how JavaScript's Number type can silently corrupt - * Stellar muxed account IDs (uint64) and how stellar-address-kit solves this. + * BigInt Precision Auditor - Single Address Comparison + * + * This tool demonstrates how JavaScript's Number type (float64) silently + * corrupts 64-bit integer IDs used in Stellar muxed addresses, and how + * stellar-address-kit's BigInt implementation preserves them. */ -// Placeholder for auditor logic +const TEST_G_ADDRESS = "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI"; +const DEFAULT_ID = 9007199254740993n; // 2^53 + 1 (First unsafe integer) + +function audit(mAddress: string) { + try { + // Safe path: decode via stellar-address-kit, keep as BigInt + const { id: safeId } = decodeMuxed(mAddress); + + // Unsafe path: extract muxed ID, convert to Number() + const unsafeId = Number(safeId); + + // Comparison logic + const diff = safeId - BigInt(unsafeId); + const isMatch = diff === 0n; + const matchStatus = isMatch ? "MATCH" : "CORRUPTED"; + + // ASCII Box-drawing output (Width: 87) + console.log("+-------------------------------------------------------------------------------------+"); + console.log(`| ${"BIGINT PRECISION AUDIT: SINGLE ADDRESS COMPARISON".padEnd(83)} |`); + console.log("+-------------------------------------------------------------------------------------+"); + console.log(`| ${`Address: ${mAddress}`.padEnd(83)} |`); + console.log("+-----------------------+-----------------------------------------------------------+"); + console.log(`| ${"PATH".padEnd(21)} | ${"DECODED ID VALUE".padEnd(59)} |`); + console.log("+-----------------------+-----------------------------------------------------------+"); + console.log(`| ${"Safe (BigInt)".padEnd(21)} | ${safeId.toString().padEnd(59)} |`); + console.log(`| ${"Unsafe (Number)".padEnd(21)} | ${unsafeId.toString().padEnd(59)} |`); + console.log("+-----------------------+-----------------------------------------------------------+"); + console.log(`| ${"Match Status".padEnd(21)} | ${matchStatus.padEnd(59)} |`); + console.log(`| ${"Numeric Difference".padEnd(21)} | ${diff.toString().padEnd(59)} |`); + console.log("+-----------------------+-----------------------------------------------------------+"); + + if (!isMatch) { + console.log("\n[!] ALERT: Precision loss detected!"); + console.log(` The ID ${safeId} is too large for Number().`); + console.log(` It has been corrupted to ${unsafeId}.`); + } else { + console.log("\n[OK] No precision loss detected for this ID."); + } + } catch (error) { + console.error(`\n[!] Error: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } +} + +// Accept one CLI argument: a muxed M-address. +const arg = process.argv[2]; +let targetAddress = arg; + +if (!arg) { + targetAddress = encodeMuxed(TEST_G_ADDRESS, DEFAULT_ID); + console.log(`No address provided. Auditing default ID: ${DEFAULT_ID}`); + console.log(`Encoded M-address: ${targetAddress}`); +} + +audit(targetAddress); diff --git a/examples/ts-backend/exchange-withdrawal-validator/package.json b/examples/ts-backend/exchange-withdrawal-validator/package.json index 9851863e..42596009 100644 --- a/examples/ts-backend/exchange-withdrawal-validator/package.json +++ b/examples/ts-backend/exchange-withdrawal-validator/package.json @@ -6,6 +6,9 @@ "express": "^4.18.3", "stellar-address-kit": "^1.0.1" }, + "scripts": { + "start": "tsx src/server.ts" + }, "devDependencies": { "@types/express": "^4.17.21", "tsx": "^4.7.1", diff --git a/examples/ts-backend/exchange-withdrawal-validator/src/public/index.html b/examples/ts-backend/exchange-withdrawal-validator/src/public/index.html index a6d62987..3d8f6c4b 100644 --- a/examples/ts-backend/exchange-withdrawal-validator/src/public/index.html +++ b/examples/ts-backend/exchange-withdrawal-validator/src/public/index.html @@ -5,12 +5,20 @@ Exchange Withdrawal Validator @@ -37,9 +45,10 @@

Exchange Withdrawal Validator

- +
+ diff --git a/examples/ts-backend/exchange-withdrawal-validator/src/server.ts b/examples/ts-backend/exchange-withdrawal-validator/src/server.ts index fecaeb6e..fd297c2d 100644 --- a/examples/ts-backend/exchange-withdrawal-validator/src/server.ts +++ b/examples/ts-backend/exchange-withdrawal-validator/src/server.ts @@ -2,6 +2,8 @@ import express from 'express'; import path from 'path'; import { fileURLToPath } from 'url'; +import { extractRouting, RoutingInput } from 'stellar-address-kit'; + const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -17,8 +19,28 @@ app.post('/api/validate', (req, res) => { }); app.post('/api/analyze', (req, res) => { - // Placeholder for address analysis - res.json({}); + const { address, memoType, memoValue } = req.body; + + try { + const input: RoutingInput = { + destination: address, + memoType: memoType === 'none' ? 'none' : memoType, + memoValue: memoValue || null, + sourceAccount: null, + }; + + const result = extractRouting(input); + + // Convert BigInt to string for JSON serialization + const serializedResult = { + ...result, + routingId: result.routingId?.toString() || null, + }; + + res.json(serializedResult); + } catch (error: any) { + res.status(400).json({ error: error.message }); + } }); app.listen(port, () => { diff --git a/packages/core-go/address/warnings.go b/packages/core-go/address/warnings.go index 6087bdcf..314e85d9 100644 --- a/packages/core-go/address/warnings.go +++ b/packages/core-go/address/warnings.go @@ -3,15 +3,17 @@ package address type WarningCode string const ( - WarnNonCanonicalAddress WarningCode = "NON_CANONICAL_ADDRESS" - WarnNonCanonicalRoutingID WarningCode = "NON_CANONICAL_ROUTING_ID" - WarnMemoIgnoredForMuxed WarningCode = "MEMO_IGNORED_FOR_MUXED" - WarnMemoPresentWithMuxed WarningCode = "MEMO_PRESENT_WITH_MUXED" - WarnContractSenderDetected WarningCode = "CONTRACT_SENDER_DETECTED" - WarnMemoTextUnroutable WarningCode = "MEMO_TEXT_UNROUTABLE" - WarnMemoIDInvalidFormat WarningCode = "MEMO_ID_INVALID_FORMAT" - WarnUnsupportedMemoType WarningCode = "UNSUPPORTED_MEMO_TYPE" - WarnInvalidDestination WarningCode = "INVALID_DESTINATION" + WarnNonCanonicalAddress WarningCode = "NON_CANONICAL_ADDRESS" + WarnNonCanonicalRoutingID WarningCode = "NON_CANONICAL_ROUTING_ID" + WarnMemoIgnoredForMuxed WarningCode = "MEMO_IGNORED_FOR_MUXED" + WarnMemoPresentWithMuxed WarningCode = "MEMO_PRESENT_WITH_MUXED" + WarnContractSenderDetected WarningCode = "CONTRACT_SENDER_DETECTED" + WarnMemoTextUnroutable WarningCode = "MEMO_TEXT_UNROUTABLE" + WarnMemoIDInvalidFormat WarningCode = "MEMO_ID_INVALID_FORMAT" + WarnUnsupportedMemoType WarningCode = "UNSUPPORTED_MEMO_TYPE" + WarnInvalidDestination WarningCode = "INVALID_DESTINATION" + WarnSmartAccountAmbiguousRouting WarningCode = "SMART_ACCOUNT_AMBIGUOUS_ROUTING" + WarnMuxedDestinationFromContract WarningCode = "MUXED_DESTINATION_FROM_CONTRACT" ) type Warning struct {