From f78eff25aefeab44f2b2dbe2d98cac4f034018fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damian=20Moli=C5=84ski?= <47773413+damian-molinski@users.noreply.github.com> Date: Tue, 19 Nov 2024 09:56:09 +0100 Subject: [PATCH 01/17] feat: VoicesModalMenu (#1227) Co-authored-by: Dominik Toton <166132265+dtscalac@users.noreply.github.com> --- .../lib/widgets/menu/voices_modal_menu.dart | 198 ++++++++++++++++++ .../apps/voices/lib/widgets/widgets.dart | 1 + 2 files changed, 199 insertions(+) create mode 100644 catalyst_voices/apps/voices/lib/widgets/menu/voices_modal_menu.dart diff --git a/catalyst_voices/apps/voices/lib/widgets/menu/voices_modal_menu.dart b/catalyst_voices/apps/voices/lib/widgets/menu/voices_modal_menu.dart new file mode 100644 index 00000000000..71d217670e5 --- /dev/null +++ b/catalyst_voices/apps/voices/lib/widgets/menu/voices_modal_menu.dart @@ -0,0 +1,198 @@ +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter/material.dart'; + +class ModalMenuItem extends Equatable { + final String id; + final String label; + final bool isEnabled; + + const ModalMenuItem({ + required this.id, + required this.label, + this.isEnabled = true, + }); + + @override + List get props => [ + id, + label, + isEnabled, + ]; +} + +class VoicesModalMenu extends StatelessWidget { + final String? selectedId; + final List menuItems; + final ValueChanged? onTap; + + const VoicesModalMenu({ + super.key, + this.selectedId, + required this.menuItems, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final onTap = this.onTap; + + return Column( + mainAxisSize: MainAxisSize.min, + children: menuItems + .map( + (item) { + return _VoicesModalMenuItemTile( + key: ValueKey('VoicesModalMenu[${item.id}]Key'), + label: item.label, + isSelected: selectedId == item.id, + isEnabled: item.isEnabled, + onTap: onTap != null ? () => onTap(item.id) : null, + ); + }, + ) + .separatedBy(const SizedBox(height: 8)) + .toList(), + ); + } +} + +class _VoicesModalMenuItemTile extends StatefulWidget { + final String label; + final bool isSelected; + final bool isEnabled; + final VoidCallback? onTap; + + const _VoicesModalMenuItemTile({ + required super.key, + required this.label, + required this.isSelected, + required this.isEnabled, + this.onTap, + }); + + @override + State<_VoicesModalMenuItemTile> createState() { + return _VoicesModalMenuItemTileState(); + } +} + +class _VoicesModalMenuItemTileState extends State<_VoicesModalMenuItemTile> { + late _BackgroundColor _backgroundColor; + late _ForegroundColor _foregroundColor; + late _LabelTextStyle _labelTextStyle; + late _BorderColor _border; + + Set get _states => { + if (!widget.isEnabled) WidgetState.disabled, + if (widget.isSelected) WidgetState.selected, + }; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + final theme = Theme.of(context); + + _backgroundColor = _BackgroundColor(theme.colorScheme.brightness); + _foregroundColor = _ForegroundColor(theme.colors); + _labelTextStyle = _LabelTextStyle(theme.textTheme); + _border = _BorderColor(theme.colorScheme.brightness); + } + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: widget.isEnabled ? widget.onTap : null, + borderRadius: BorderRadius.circular(8), + child: AnimatedContainer( + duration: const Duration(milliseconds: 250), + constraints: const BoxConstraints(minWidth: 320), + decoration: BoxDecoration( + color: _backgroundColor.resolve(_states), + border: _border.resolve(_states), + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16) + .add(const EdgeInsets.only(bottom: 2)), + child: Text( + widget.label, + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: _labelTextStyle + .resolve(_states) + .copyWith(color: _foregroundColor.resolve(_states)), + ), + ), + ); + } +} + +class _BackgroundColor extends WidgetStateProperty { + final Brightness _brightness; + + _BackgroundColor(this._brightness); + + @override + Color? resolve(Set states) { + if (states.contains(WidgetState.selected)) { + // TODO(damian-molinski): Those colors are not using properties. + // TODO(damian-molinski): Dark/Transparent/On primary surface P40 016 + // TODO(damian-molinski): Light/Transparent/On surface P40 08 + return switch (_brightness) { + Brightness.dark => const Color(0x29123cd3), + Brightness.light => const Color(0x1f123cd3), + }; + } + + return null; + } +} + +class _ForegroundColor extends WidgetStateProperty { + final VoicesColorScheme _colors; + + _ForegroundColor(this._colors); + + @override + Color? resolve(Set states) { + if (states.contains(WidgetState.disabled)) { + return _colors.textDisabled; + } + + return _colors.textOnPrimaryLevel1; + } +} + +class _LabelTextStyle extends WidgetStateProperty { + final TextTheme _textTheme; + + _LabelTextStyle(this._textTheme); + + @override + TextStyle resolve(Set states) { + if (states.contains(WidgetState.selected)) { + return _textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.bold); + } + + return _textTheme.bodyLarge!; + } +} + +class _BorderColor extends WidgetStateProperty { + final Brightness _brightness; + + _BorderColor(this._brightness); + + @override + BoxBorder resolve(Set states) { + // TODO(damian-molinski): Those colors are not using properties. + // TODO(damian-molinski): Elevations/On surface/Neutral/Transparent/on surface N10 08 + // TODO(damian-molinski): Elevations/On surface/Neutral/Transparent/on surface N10 08 + return switch (_brightness) { + Brightness.dark => Border.all(color: const Color(0x1fbfc8d9)), + Brightness.light => Border.all(color: const Color(0x14212a3d)), + }; + } +} diff --git a/catalyst_voices/apps/voices/lib/widgets/widgets.dart b/catalyst_voices/apps/voices/lib/widgets/widgets.dart index d3ad43f3ba5..d66d06f8d12 100644 --- a/catalyst_voices/apps/voices/lib/widgets/widgets.dart +++ b/catalyst_voices/apps/voices/lib/widgets/widgets.dart @@ -46,6 +46,7 @@ export 'indicators/voices_status_indicator.dart'; export 'list/bullet_list.dart'; export 'menu/voices_list_tile.dart'; export 'menu/voices_menu.dart'; +export 'menu/voices_modal_menu.dart'; export 'menu/voices_node_menu.dart'; export 'menu/voices_wallet_tile.dart'; export 'modals/voices_alert_dialog.dart'; From 62540c3548e3d38bdfc9dcbf7a24296ca3a0eabc Mon Sep 17 00:00:00 2001 From: Ryszard Schossler <51096731+LynxLynxx@users.noreply.github.com> Date: Wed, 20 Nov 2024 14:50:19 +0100 Subject: [PATCH 02/17] feat(cat-voices): No proposal state widget (#1236) * feat(voices): add NoProposals widget and corresponding tests, plus asset and localization updates * feat(voices): introduce VoicesImagesScheme widget and integrate it into NoProposals, add no proposal foreground asset * feat(voices): add NoProposals widget and corresponding tests, plus asset and localization updates * feat(voices): introduce VoicesImagesScheme widget and integrate it into NoProposals, add no proposal foreground asset * feat(voices): add missing background property to VoicesImagesScheme widget for improved layout flexibility --------- Co-authored-by: Dominik Toton <166132265+dtscalac@users.noreply.github.com> --- .../workspace/workspace_guidance_view.dart | 39 +++--- .../widgets/images/voices_image_scheme.dart | 23 ++++ .../lib/widgets/proposals/no_proposals.dart | 71 +++++++++++ .../widgets/proposals/no_proposals_test.dart | 116 ++++++++++++++++++ .../assets/images/no_proposal_foreground.svg | 41 +++++++ .../lib/l10n/intl_en.arb | 8 ++ 6 files changed, 279 insertions(+), 19 deletions(-) create mode 100644 catalyst_voices/apps/voices/lib/widgets/images/voices_image_scheme.dart create mode 100644 catalyst_voices/apps/voices/lib/widgets/proposals/no_proposals.dart create mode 100644 catalyst_voices/apps/voices/test/widgets/proposals/no_proposals_test.dart create mode 100644 catalyst_voices/packages/internal/catalyst_voices_assets/assets/images/no_proposal_foreground.svg diff --git a/catalyst_voices/apps/voices/lib/pages/workspace/workspace_guidance_view.dart b/catalyst_voices/apps/voices/lib/pages/workspace/workspace_guidance_view.dart index fd580e3ac84..279aabae2b2 100644 --- a/catalyst_voices/apps/voices/lib/pages/workspace/workspace_guidance_view.dart +++ b/catalyst_voices/apps/voices/lib/pages/workspace/workspace_guidance_view.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; class GuidanceView extends StatefulWidget { final List guidances; + const GuidanceView(this.guidances, {super.key}); @override @@ -18,25 +19,6 @@ class _GuidanceViewState extends State { GuidanceType? selectedType; - @override - void initState() { - super.initState(); - filteredGuidances - ..clear() - ..addAll(widget.guidances); - } - - @override - void didUpdateWidget(GuidanceView oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.guidances != widget.guidances) { - filteredGuidances - ..clear() - ..addAll(widget.guidances); - _filterGuidances(selectedType); - } - } - @override Widget build(BuildContext context) { return Column( @@ -74,6 +56,25 @@ class _GuidanceViewState extends State { ); } + @override + void didUpdateWidget(GuidanceView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.guidances != widget.guidances) { + filteredGuidances + ..clear() + ..addAll(widget.guidances); + _filterGuidances(selectedType); + } + } + + @override + void initState() { + super.initState(); + filteredGuidances + ..clear() + ..addAll(widget.guidances); + } + void _filterGuidances(GuidanceType? type) { selectedType = type; filteredGuidances diff --git a/catalyst_voices/apps/voices/lib/widgets/images/voices_image_scheme.dart b/catalyst_voices/apps/voices/lib/widgets/images/voices_image_scheme.dart new file mode 100644 index 00000000000..04a77bb328f --- /dev/null +++ b/catalyst_voices/apps/voices/lib/widgets/images/voices_image_scheme.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; + +class VoicesImagesScheme extends StatelessWidget { + final Widget image; + final Widget background; + + const VoicesImagesScheme({ + super.key, + required this.image, + required this.background, + }); + + @override + Widget build(BuildContext context) { + return Stack( + alignment: Alignment.center, + children: [ + background, + image, + ], + ); + } +} diff --git a/catalyst_voices/apps/voices/lib/widgets/proposals/no_proposals.dart b/catalyst_voices/apps/voices/lib/widgets/proposals/no_proposals.dart new file mode 100644 index 00000000000..a3362192e5a --- /dev/null +++ b/catalyst_voices/apps/voices/lib/widgets/proposals/no_proposals.dart @@ -0,0 +1,71 @@ +import 'package:catalyst_voices/widgets/images/voices_image_scheme.dart'; +import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:flutter/material.dart'; + +class NoProposals extends StatelessWidget { + final String? title; + final String? description; + + const NoProposals({ + super.key, + this.title, + this.description, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final textTheme = theme.textTheme; + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 64), + child: Column( + children: [ + VoicesImagesScheme( + image: CatalystSvgPicture.asset( + VoicesAssets.images.noProposalForeground.path, + ), + background: Container( + height: 180, + decoration: BoxDecoration( + color: theme.colors.onSurfaceNeutral08, + shape: BoxShape.circle, + ), + ), + ), + const SizedBox(height: 24), + SizedBox( + width: 430, + child: Column( + children: [ + Text( + _buildTitle(context), + style: textTheme.titleMedium + ?.copyWith(color: theme.colors.textOnPrimaryLevel1), + ), + const SizedBox(height: 8), + Text( + _buildDescription(context), + style: textTheme.bodyMedium + ?.copyWith(color: theme.colors.textOnPrimaryLevel1), + textAlign: TextAlign.center, + ), + ], + ), + ), + ], + ), + ), + ); + } + + String _buildTitle(BuildContext context) { + return title ?? context.l10n.noProposalStateTitle; + } + + String _buildDescription(BuildContext context) { + return description ?? context.l10n.noProposalStateDescription; + } +} diff --git a/catalyst_voices/apps/voices/test/widgets/proposals/no_proposals_test.dart b/catalyst_voices/apps/voices/test/widgets/proposals/no_proposals_test.dart new file mode 100644 index 00000000000..78e7f7550e0 --- /dev/null +++ b/catalyst_voices/apps/voices/test/widgets/proposals/no_proposals_test.dart @@ -0,0 +1,116 @@ +import 'package:catalyst_voices/widgets/proposals/no_proposals.dart'; +import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + group('NoProposals Widget Tests', () { + testWidgets('Renders correctly with default values', (tester) async { + await tester.pumpApp( + const NoProposals(), + ); + await tester.pumpAndSettle(); + + expect(find.byType(CatalystSvgPicture), findsOneWidget); + expect(find.byType(Text), findsNWidgets(2)); + expect(find.text('No draft proposals yet'), findsOneWidget); + expect( + find.text( + // ignore: lines_longer_than_80_chars + 'Discovery space will show draft proposals you can comment on, currently there are no draft proposals.', + ), + findsOneWidget, + ); + }); + + testWidgets('Renders correctly with custom values', (tester) async { + await tester.pumpApp( + const NoProposals( + title: 'Custom Title', + description: 'Custom Description', + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(CatalystSvgPicture), findsOneWidget); + expect(find.text('Custom Title'), findsOneWidget); + expect(find.text('Custom Description'), findsOneWidget); + }); + + testWidgets('Uses correct custom color scheme', (tester) async { + const colors = + VoicesColorScheme.optional(textOnPrimaryLevel1: Colors.red); + await tester.pumpApp( + voicesColors: colors, + const NoProposals( + title: 'Custom Title', + description: 'Custom Description', + ), + ); + await tester.pumpAndSettle(); + + final titleText = tester.widget( + find.byType(Text).first, + ); + + expect( + titleText.style?.color, + colors.textOnPrimaryLevel1, + ); + + final descriptionText = tester.widget( + find.byType(Text).last, + ); + + expect( + descriptionText.style?.color, + colors.textOnPrimaryLevel1, + ); + }); + + testWidgets( + 'Proposal image changes depending on theme brightness', + (tester) async { + // Given + const widget = NoProposals(); + + // When - Light theme + await tester.pumpApp( + widget, + theme: ThemeData(brightness: Brightness.light), + voicesColors: const VoicesColorScheme.optional(), + ); + await tester.pumpAndSettle(); + + // Then - Light theme + final lightThemeImage = tester.widget( + find.byType(CatalystSvgPicture), + ); + expect( + lightThemeImage, + isA(), + ); + + // When - Dark theme + await tester.pumpApp( + widget, + theme: ThemeData(brightness: Brightness.dark), + voicesColors: const VoicesColorScheme.optional(), + ); + await tester.pumpAndSettle(); + + // Then - Dark theme + final darkThemeImage = tester.widget( + find.byType(CatalystSvgPicture), + ); + expect( + darkThemeImage, + isA(), + ); + }, + ); + }); +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_assets/assets/images/no_proposal_foreground.svg b/catalyst_voices/packages/internal/catalyst_voices_assets/assets/images/no_proposal_foreground.svg new file mode 100644 index 00000000000..df9c9749f62 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_assets/assets/images/no_proposal_foreground.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/catalyst_voices/packages/internal/catalyst_voices_localization/lib/l10n/intl_en.arb b/catalyst_voices/packages/internal/catalyst_voices_localization/lib/l10n/intl_en.arb index de5f9c1f9a1..bcd2e5a2aa1 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_localization/lib/l10n/intl_en.arb +++ b/catalyst_voices/packages/internal/catalyst_voices_localization/lib/l10n/intl_en.arb @@ -986,5 +986,13 @@ "noGuidanceForThisSection": "There is no guidance for this section", "@noGuidanceForThisSection": { "description": "Message when there is no guidance for this section" + }, + "noProposalStateDescription": "Discovery space will show draft proposals you can comment on, currently there are no draft proposals.", + "@noProposalStateDescription": { + "description": "Description shown when there are no proposals in the proposals tab" + }, + "noProposalStateTitle": "No draft proposals yet", + "@noProposalStateTitle": { + "description": "Title shown when there are no proposals in the proposals tab" } } \ No newline at end of file From 7d08e5dc79eb05b982580eca655fe513ea898d84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damian=20Moli=C5=84ski?= <47773413+damian-molinski@users.noreply.github.com> Date: Mon, 25 Nov 2024 09:32:50 +0100 Subject: [PATCH 03/17] feat(cat-voices): campaign details and information (#1239) * feat: VoicesExpansionTile * feat: CampaignDetailsTile * fix: formatting --------- Co-authored-by: Dominik Toton <166132265+dtscalac@users.noreply.github.com> --- .../lib/common/formatters/date_formatter.dart | 29 ++- .../campaign/campaign_details_tile.dart | 219 ++++++++++++++++++ .../widgets/tiles/voices_expansion_tile.dart | 107 +++++++++ .../apps/voices/lib/widgets/widgets.dart | 1 + .../formatters/date_formatter_test.dart | 115 ++++++--- .../lib/l10n/intl_en.arb | 15 ++ 6 files changed, 446 insertions(+), 40 deletions(-) create mode 100644 catalyst_voices/apps/voices/lib/widgets/modals/campaign/campaign_details_tile.dart create mode 100644 catalyst_voices/apps/voices/lib/widgets/tiles/voices_expansion_tile.dart diff --git a/catalyst_voices/apps/voices/lib/common/formatters/date_formatter.dart b/catalyst_voices/apps/voices/lib/common/formatters/date_formatter.dart index 0829da4ce11..5012fc0ca93 100644 --- a/catalyst_voices/apps/voices/lib/common/formatters/date_formatter.dart +++ b/catalyst_voices/apps/voices/lib/common/formatters/date_formatter.dart @@ -10,10 +10,14 @@ abstract class DateFormatter { /// - Yesterday /// - 2 days ago /// - Other cases: yMMMMd date format. - static String formatRecentDate(VoicesLocalizations l10n, DateTime dateTime) { - final now = DateTimeExt.now(); + static String formatRecentDate( + VoicesLocalizations l10n, + DateTime dateTime, { + DateTime? from, + }) { + from ??= DateTimeExt.now(); - final today = DateTime(now.year, now.month, now.day, 12); + final today = DateTime(from.year, from.month, from.day, 12); if (dateTime.isSameDateAs(today)) return l10n.today; final tomorrow = today.plusDays(1); @@ -27,4 +31,23 @@ abstract class DateFormatter { return DateFormat.yMMMMd().format(dateTime); } + + static String formatInDays( + VoicesLocalizations l10n, + DateTime dateTime, { + DateTime? from, + }) { + from ??= DateTimeExt.now(); + + final days = dateTime.isAfter(from) ? dateTime.difference(from).inDays : 0; + + return l10n.inXDays(days); + } + + static String formatShortMonth( + VoicesLocalizations l10n, + DateTime dateTime, + ) { + return DateFormat.MMM().format(dateTime); + } } diff --git a/catalyst_voices/apps/voices/lib/widgets/modals/campaign/campaign_details_tile.dart b/catalyst_voices/apps/voices/lib/widgets/modals/campaign/campaign_details_tile.dart new file mode 100644 index 00000000000..f07a02a6e4e --- /dev/null +++ b/catalyst_voices/apps/voices/lib/widgets/modals/campaign/campaign_details_tile.dart @@ -0,0 +1,219 @@ +import 'package:catalyst_voices/common/formatters/date_formatter.dart'; +import 'package:catalyst_voices/widgets/tiles/voices_expansion_tile.dart'; +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; +import 'package:flutter/material.dart'; + +class CampaignDetailsTile extends StatelessWidget { + final String description; + final DateTime publishDate; + final DateTime startDate; + final DateTime endDate; + final int categoriesCount; + final int proposalsCount; + + const CampaignDetailsTile({ + super.key, + required this.description, + required this.publishDate, + required this.startDate, + required this.endDate, + required this.categoriesCount, + required this.proposalsCount, + }); + + @override + Widget build(BuildContext context) { + return VoicesExpansionTile( + initiallyExpanded: true, + title: Text(context.l10n.campaignDetails), + children: [ + _Body( + description: description, + ), + const SizedBox(height: 16 + 24), + _CampaignData( + publishDate: publishDate, + startDate: startDate, + endDate: endDate, + categoriesCount: categoriesCount, + proposalsCount: proposalsCount, + ), + ], + ); + } +} + +class _Body extends StatelessWidget { + final String description; + + const _Body({ + required this.description, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final textTheme = theme.textTheme; + final colors = theme.colors; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + context.l10n.description, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.titleSmall?.copyWith( + color: colors.textOnPrimaryLevel1, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 14), + Text( + description, + style: textTheme.bodyLarge?.copyWith( + color: colors.textOnPrimaryLevel1, + ), + ), + ], + ); + } +} + +class _CampaignData extends StatelessWidget { + final DateTime publishDate; + final DateTime startDate; + final DateTime endDate; + final int categoriesCount; + final int proposalsCount; + + const _CampaignData({ + required this.publishDate, + required this.startDate, + required this.endDate, + required this.categoriesCount, + required this.proposalsCount, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colors; + final l10n = context.l10n; + + return Container( + decoration: BoxDecoration( + color: colors.onSurfacePrimary012, + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + child: Row( + children: [ + _CampaignDataTile( + key: const ValueKey('StartDateTileKey'), + title: l10n.startDate, + subtitle: DateFormatter.formatInDays(l10n, startDate), + value: startDate.day, + valueSuffix: DateFormatter.formatShortMonth(l10n, startDate), + ), + _CampaignDataTile( + key: const ValueKey('EndDateTileKey'), + title: l10n.endDate, + subtitle: DateFormatter.formatInDays(l10n, endDate), + value: endDate.day, + valueSuffix: DateFormatter.formatShortMonth(l10n, endDate), + ), + _CampaignDataTile( + key: const ValueKey('CategoriesTileKey'), + title: l10n.categories, + subtitle: DateFormatter.formatInDays( + l10n, + DateTime.now(), + from: publishDate, + ), + value: categoriesCount, + ), + _CampaignDataTile( + key: const ValueKey('ProposalsTileKey'), + title: l10n.proposals, + subtitle: l10n.totalSubmitted, + value: proposalsCount, + ), + ] + .map((e) => Expanded(child: e)) + .separatedBy(const SizedBox(width: 16)) + .toList(), + ), + ); + } +} + +class _CampaignDataTile extends StatelessWidget { + final String title; + final String subtitle; + final int value; + final String? valueSuffix; + + const _CampaignDataTile({ + super.key, + required this.title, + required this.subtitle, + required this.value, + this.valueSuffix, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final textTheme = theme.textTheme; + final colors = theme.colors; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + title, + style: textTheme.titleSmall?.copyWith( + color: colors.textOnPrimaryLevel1, + ), + ), + Text( + subtitle, + style: textTheme.bodySmall?.copyWith( + // TODO(damian-molinski): This color does not have property. + // Colors/sys color neutral md ref/N60 + color: const Color(0xFF7F90B3), + ), + ), + const SizedBox(height: 16), + Row( + textBaseline: TextBaseline.alphabetic, + crossAxisAlignment: valueSuffix != null + ? CrossAxisAlignment.baseline + : CrossAxisAlignment.end, + children: [ + Text( + '$value', + style: textTheme.headlineLarge?.copyWith( + color: colors.textOnPrimaryLevel1, + ), + ), + if (valueSuffix != null) ...[ + const SizedBox(width: 4), + Text( + valueSuffix!, + style: textTheme.titleMedium?.copyWith( + color: colors.textOnPrimaryLevel1, + ), + ), + ], + ], + ), + ], + ); + } +} diff --git a/catalyst_voices/apps/voices/lib/widgets/tiles/voices_expansion_tile.dart b/catalyst_voices/apps/voices/lib/widgets/tiles/voices_expansion_tile.dart new file mode 100644 index 00000000000..46aca16a6a8 --- /dev/null +++ b/catalyst_voices/apps/voices/lib/widgets/tiles/voices_expansion_tile.dart @@ -0,0 +1,107 @@ +import 'package:catalyst_voices/widgets/buttons/voices_buttons.dart'; +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; +import 'package:flutter/material.dart'; + +class VoicesExpansionTile extends StatefulWidget { + final Widget title; + final List children; + final bool initiallyExpanded; + + const VoicesExpansionTile({ + super.key, + required this.title, + this.children = const [], + this.initiallyExpanded = false, + }); + + @override + State createState() => _VoicesExpansionTileState(); +} + +class _VoicesExpansionTileState extends State { + final _controller = ExpansionTileController(); + + bool _isExpanded = false; + + @override + void initState() { + super.initState(); + _isExpanded = widget.initiallyExpanded; + } + + @override + Widget build(BuildContext context) { + return _ThemeOverride( + child: Builder( + builder: (context) { + final theme = Theme.of(context); + + return ExpansionTile( + title: DefaultTextStyle( + style: theme.textTheme.titleLarge ?? const TextStyle(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + child: widget.title, + ), + trailing: ChevronExpandButton( + isExpanded: _isExpanded, + onTap: _toggleExpand, + ), + controller: _controller, + initiallyExpanded: _isExpanded, + onExpansionChanged: _updateExpended, + children: widget.children, + ); + }, + ), + ); + } + + void _updateExpended(bool value) { + setState(() { + _isExpanded = value; + }); + } + + void _toggleExpand() { + if (_controller.isExpanded) { + _controller.collapse(); + } else { + _controller.expand(); + } + } +} + +class _ThemeOverride extends StatelessWidget { + final Widget child; + + const _ThemeOverride({ + required this.child, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Theme( + data: theme.copyWith( + // listTileTheme is required here because ExpansionTile does not let + // us set shape or ripple used internally by ListTile. + listTileTheme: const ListTileThemeData(shape: RoundedRectangleBorder()), + expansionTileTheme: ExpansionTileThemeData( + backgroundColor: theme.colors.elevationsOnSurfaceNeutralLv0, + collapsedBackgroundColor: theme.colors.elevationsOnSurfaceNeutralLv0, + tilePadding: const EdgeInsets.fromLTRB(24, 8, 12, 8), + childrenPadding: const EdgeInsets.fromLTRB(24, 16, 24, 24), + textColor: theme.colors.textOnPrimaryLevel1, + collapsedTextColor: theme.colors.textOnPrimaryLevel1, + iconColor: theme.colors.iconsForeground, + collapsedIconColor: theme.colors.iconsForeground, + shape: const RoundedRectangleBorder(), + collapsedShape: const RoundedRectangleBorder(), + ), + ), + child: child, + ); + } +} diff --git a/catalyst_voices/apps/voices/lib/widgets/widgets.dart b/catalyst_voices/apps/voices/lib/widgets/widgets.dart index d66d06f8d12..a43e5437a5a 100644 --- a/catalyst_voices/apps/voices/lib/widgets/widgets.dart +++ b/catalyst_voices/apps/voices/lib/widgets/widgets.dart @@ -72,6 +72,7 @@ export 'text_field/voices_autocomplete.dart'; export 'text_field/voices_email_text_field.dart'; export 'text_field/voices_password_text_field.dart'; export 'text_field/voices_text_field.dart'; +export 'tiles/voices_expansion_tile.dart'; export 'tiles/voices_nav_tile.dart'; export 'toggles/voices_checkbox.dart'; export 'toggles/voices_checkbox_group.dart'; diff --git a/catalyst_voices/apps/voices/test/common/formatters/date_formatter_test.dart b/catalyst_voices/apps/voices/test/common/formatters/date_formatter_test.dart index 0b163672d45..a07b0dee89f 100644 --- a/catalyst_voices/apps/voices/test/common/formatters/date_formatter_test.dart +++ b/catalyst_voices/apps/voices/test/common/formatters/date_formatter_test.dart @@ -1,53 +1,94 @@ import 'package:catalyst_voices/common/formatters/date_formatter.dart'; -import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:catalyst_voices_localization/generated/catalyst_voices_localizations_en.dart'; import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:intl/intl.dart'; -class _FakeVoicesLocalizations extends Fake implements VoicesLocalizations { - @override - String get today => 'Today'; - @override - String get tomorrow => 'Tomorrow'; - @override - String get yesterday => 'Yesterday'; - @override - String get twoDaysAgo => '2 days ago'; -} - void main() { group(DateFormatter, () { - final l10n = _FakeVoicesLocalizations(); + final l10n = VoicesLocalizationsEn(); - test('should return "Today" for today\'s date', () { - final today = DateTimeExt.now(); - final result = DateFormatter.formatRecentDate(l10n, today); - expect(result, l10n.today); - }); + group('formatRecentDate', () { + test('should return "Today" for today\'s date', () { + final today = DateTimeExt.now(); + final result = DateFormatter.formatRecentDate(l10n, today); + expect(result, l10n.today); + }); - test('should return "Tomorrow" for tomorrow\'s date', () { - final tomorrow = DateTimeExt.now().plusDays(1); - final result = DateFormatter.formatRecentDate(l10n, tomorrow); - expect(result, l10n.tomorrow); - }); + test('should return "Tomorrow" for tomorrow\'s date', () { + final tomorrow = DateTimeExt.now().plusDays(1); + final result = DateFormatter.formatRecentDate(l10n, tomorrow); + expect(result, l10n.tomorrow); + }); - test('should return "Yesterday" for yesterday\'s date', () { - final yesterday = DateTimeExt.now().minusDays(1); - final result = DateFormatter.formatRecentDate(l10n, yesterday); - expect(result, l10n.yesterday); - }); + test('should return "Yesterday" for yesterday\'s date', () { + final yesterday = DateTimeExt.now().minusDays(1); + final result = DateFormatter.formatRecentDate(l10n, yesterday); + expect(result, l10n.yesterday); + }); + + test('should return "2 days ago" for a date 2 days ago', () { + final twoDaysAgo = DateTimeExt.now().minusDays(2); + final result = DateFormatter.formatRecentDate(l10n, twoDaysAgo); + expect(result, l10n.twoDaysAgo); + }); - test('should return "2 days ago" for a date 2 days ago', () { - final twoDaysAgo = DateTimeExt.now().minusDays(2); - final result = DateFormatter.formatRecentDate(l10n, twoDaysAgo); - expect(result, l10n.twoDaysAgo); + test('should return formatted date for older dates', () { + final pastDate = DateTimeExt.now().minusDays(10); + final result = DateFormatter.formatRecentDate(l10n, pastDate); + final expectedFormat = DateFormat.yMMMMd().format(pastDate); + expect(result, expectedFormat); + }); }); - test('should return formatted date for older dates', () { - final pastDate = DateTimeExt.now().minusDays(10); - final result = DateFormatter.formatRecentDate(l10n, pastDate); - final expectedFormat = DateFormat.yMMMMd().format(pastDate); - expect(result, expectedFormat); + group('formatInDays', () { + test('returns 20 days when in comparing to 20 days in future', () { + // Given + final publishDate = DateTime(2024, 11, 20); + final now = DateTime(2024, 11, 0); + + // When + final result = DateFormatter.formatInDays( + l10n, + publishDate, + from: now, + ); + + // Then + expect(result, 'In 20 days'); + }); + + test('returns 0 days when in comparing to past', () { + // Given + final publishDate = DateTime(2024, 2, 10); + final now = DateTime(2024, 11, 0); + + // When + final result = DateFormatter.formatInDays( + l10n, + publishDate, + from: now, + ); + + // Then + expect(result, 'In 0 days'); + }); + + test('returns 1 day when in comparing to 1 day in future', () { + // Given + final publishDate = DateTime(2024, 11, 1); + final now = DateTime(2024, 11, 0); + + // When + final result = DateFormatter.formatInDays( + l10n, + publishDate, + from: now, + ); + + // Then + expect(result, 'In 1 day'); + }); }); }); } diff --git a/catalyst_voices/packages/internal/catalyst_voices_localization/lib/l10n/intl_en.arb b/catalyst_voices/packages/internal/catalyst_voices_localization/lib/l10n/intl_en.arb index bcd2e5a2aa1..62c5d02f75c 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_localization/lib/l10n/intl_en.arb +++ b/catalyst_voices/packages/internal/catalyst_voices_localization/lib/l10n/intl_en.arb @@ -994,5 +994,20 @@ "noProposalStateTitle": "No draft proposals yet", "@noProposalStateTitle": { "description": "Title shown when there are no proposals in the proposals tab" + }, + "campaignDetails": "Campaign Details", + "description": "Description", + "startDate": "Start Date", + "endDate": "End Date", + "categories": "Categories", + "proposals": "Proposals", + "totalSubmitted": "Total submitted", + "inXDays": "{x, plural, =1{In {x} day} other{In {x} days}}", + "@inXDays": { + "placeholders": { + "x": { + "type": "int" + } + } } } \ No newline at end of file From fab8ca0ba23e849d4175e337c8f26bde6ac56012 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damian=20Moli=C5=84ski?= <47773413+damian-molinski@users.noreply.github.com> Date: Mon, 25 Nov 2024 14:58:50 +0100 Subject: [PATCH 04/17] feat(cat-voices): campaign categories (#1251) * chore: tile scaffolding * feat: finish component * fix: mutable classes should not be marked as @immutable * fix: sections list comparing with listEquals * fix: vault / keychain tests --- .../voices/lib/widgets/menu/voices_menu.dart | 34 ++-- .../lib/widgets/menu/voices_modal_menu.dart | 23 +-- .../campaign/campaign_categories_tile.dart | 165 ++++++++++++++++++ .../test/widgets/menu/voices_menu_test.dart | 16 +- .../lib/l10n/intl_en.arb | 4 +- .../lib/src/keychain/vault_keychain.dart | 3 - .../storage/vault/secure_storage_vault.dart | 14 +- .../lib/src/storage/vault/vault.dart | 4 +- .../vault_keychain_provider_test.dart | 5 +- .../src/keychain/vault_keychain_test.dart | 4 +- .../test/src/user/user_service_test.dart | 10 +- .../lib/src/campaign/campaign_category.dart | 14 ++ .../lib/src/campaign/campaign_section.dart | 31 ++++ .../lib/src/catalyst_voices_view_models.dart | 4 + .../lib/src/menu/menu_item.dart | 31 ++++ .../lib/src/menu/popup_menu_item.dart | 32 ++++ .../lib/examples/voices_menu_example.dart | 30 ++-- 17 files changed, 342 insertions(+), 82 deletions(-) create mode 100644 catalyst_voices/apps/voices/lib/widgets/modals/campaign/campaign_categories_tile.dart create mode 100644 catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_category.dart create mode 100644 catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_section.dart create mode 100644 catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/menu/menu_item.dart create mode 100644 catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/menu/popup_menu_item.dart diff --git a/catalyst_voices/apps/voices/lib/widgets/menu/voices_menu.dart b/catalyst_voices/apps/voices/lib/widgets/menu/voices_menu.dart index 0a98b997093..178e9bbccc8 100644 --- a/catalyst_voices/apps/voices/lib/widgets/menu/voices_menu.dart +++ b/catalyst_voices/apps/voices/lib/widgets/menu/voices_menu.dart @@ -1,4 +1,5 @@ import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; +import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; import 'package:flutter/material.dart'; /// A menu of the app that @@ -68,7 +69,7 @@ class _MenuButton extends StatelessWidget { final textStyle = textTheme.bodyMedium?.copyWith( color: - menuItem.enabled ? textTheme.bodySmall?.color : theme.disabledColor, + menuItem.isEnabled ? textTheme.bodySmall?.color : theme.disabledColor, ); final children = menuChildren; @@ -85,7 +86,7 @@ class _MenuButton extends StatelessWidget { child: IconTheme( data: IconThemeData( size: 24, - color: menuItem.enabled + color: menuItem.isEnabled ? textTheme.bodySmall?.color : theme.disabledColor, ), @@ -138,32 +139,29 @@ class _MenuButton extends StatelessWidget { } /// Model representing Menu Item -class MenuItem { - final int id; - final String label; - final Widget? icon; - final bool showDivider; - final bool enabled; - - MenuItem({ - required this.id, - required this.label, - this.icon, - this.showDivider = false, - this.enabled = true, +final class MenuItem extends BasicPopupMenuItem { + const MenuItem({ + required super.id, + required super.label, + super.isEnabled = true, + super.icon, + super.showDivider = false, }); } /// Model representing Submenu Item /// and extending from MenuItem -class SubMenuItem extends MenuItem { - List children; +final class SubMenuItem extends MenuItem { + final List children; - SubMenuItem({ + const SubMenuItem({ required super.id, required super.label, required this.children, super.icon, super.showDivider, }); + + @override + List get props => super.props + [children]; } diff --git a/catalyst_voices/apps/voices/lib/widgets/menu/voices_modal_menu.dart b/catalyst_voices/apps/voices/lib/widgets/menu/voices_modal_menu.dart index 71d217670e5..61b24038779 100644 --- a/catalyst_voices/apps/voices/lib/widgets/menu/voices_modal_menu.dart +++ b/catalyst_voices/apps/voices/lib/widgets/menu/voices_modal_menu.dart @@ -1,30 +1,11 @@ import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; -import 'package:equatable/equatable.dart'; +import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; import 'package:flutter/material.dart'; -class ModalMenuItem extends Equatable { - final String id; - final String label; - final bool isEnabled; - - const ModalMenuItem({ - required this.id, - required this.label, - this.isEnabled = true, - }); - - @override - List get props => [ - id, - label, - isEnabled, - ]; -} - class VoicesModalMenu extends StatelessWidget { final String? selectedId; - final List menuItems; + final List menuItems; final ValueChanged? onTap; const VoicesModalMenu({ diff --git a/catalyst_voices/apps/voices/lib/widgets/modals/campaign/campaign_categories_tile.dart b/catalyst_voices/apps/voices/lib/widgets/modals/campaign/campaign_categories_tile.dart new file mode 100644 index 00000000000..644ffcb9438 --- /dev/null +++ b/catalyst_voices/apps/voices/lib/widgets/modals/campaign/campaign_categories_tile.dart @@ -0,0 +1,165 @@ +import 'package:catalyst_voices/widgets/menu/voices_modal_menu.dart'; +import 'package:catalyst_voices/widgets/tiles/voices_expansion_tile.dart'; +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class CampaignCategoriesTile extends StatefulWidget { + final List sections; + + const CampaignCategoriesTile({ + super.key, + required this.sections, + }); + + @override + State createState() => _CampaignCategoriesTileState(); +} + +class _CampaignCategoriesTileState extends State { + String? _selectedSectionId; + + @override + void initState() { + super.initState(); + + _selectedSectionId = widget.sections.firstOrNull?.id; + } + + @override + void didUpdateWidget(covariant CampaignCategoriesTile oldWidget) { + super.didUpdateWidget(oldWidget); + + if (!listEquals(widget.sections, oldWidget.sections)) { + if (!widget.sections.any((element) => element.id == _selectedSectionId)) { + _selectedSectionId = widget.sections.firstOrNull?.id; + } + } + } + + @override + Widget build(BuildContext context) { + final selectedSection = widget.sections + .singleWhereOrNull((element) => element.id == _selectedSectionId); + + return VoicesExpansionTile( + initiallyExpanded: true, + title: Text(context.l10n.campaignCategories), + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _Menu( + selectedId: _selectedSectionId, + menuItems: widget.sections, + onTap: _updateSelection, + ), + const SizedBox(width: 32), + Expanded( + child: selectedSection != null + ? _Details(section: selectedSection) + : const SizedBox(), + ), + ], + ), + ], + ); + } + + void _updateSelection(String id) { + setState(() { + _selectedSectionId = id; + }); + } +} + +class _Menu extends StatelessWidget { + final String? selectedId; + final List menuItems; + final ValueChanged onTap; + + const _Menu({ + this.selectedId, + required this.menuItems, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final textTheme = theme.textTheme; + final colors = theme.colors; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16), + Text( + context.l10n.cardanoUseCases, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.titleSmall?.copyWith( + color: colors.textOnPrimaryLevel0, + ), + ), + const SizedBox(height: 12), + VoicesModalMenu( + selectedId: selectedId, + menuItems: menuItems, + onTap: onTap, + ), + const SizedBox(height: 16), + ], + ); + } +} + +class _Details extends StatelessWidget { + final CampaignSection section; + + const _Details({ + required this.section, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final textTheme = theme.textTheme; + final colors = theme.colors; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 48), + Text( + section.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.headlineMedium?.copyWith( + color: colors.textOnPrimaryLevel1, + ), + ), + const SizedBox(height: 24), + Text( + section.title, + style: textTheme.titleLarge?.copyWith( + color: colors.textOnPrimaryLevel0, + ), + ), + const SizedBox(height: 16), + Text( + section.body, + style: textTheme.bodyLarge?.copyWith( + color: colors.textOnPrimaryLevel1, + ), + ), + const SizedBox(height: 32), + ], + ); + } +} diff --git a/catalyst_voices/apps/voices/test/widgets/menu/voices_menu_test.dart b/catalyst_voices/apps/voices/test/widgets/menu/voices_menu_test.dart index 4ceb87006f3..2e09d8fb64a 100644 --- a/catalyst_voices/apps/voices/test/widgets/menu/voices_menu_test.dart +++ b/catalyst_voices/apps/voices/test/widgets/menu/voices_menu_test.dart @@ -9,34 +9,34 @@ import '../../helpers/helpers.dart'; void main() { final menu = [ MenuItem( - id: 1, + id: '1', label: 'Rename', icon: VoicesAssets.icons.pencil.buildIcon(), ), SubMenuItem( - id: 2, + id: '2', label: 'Move Private Team', icon: VoicesAssets.icons.switchHorizontal.buildIcon(), - children: [ + children: const [ MenuItem( - id: 3, + id: '3', label: 'Team 1: The Vikings', ), MenuItem( - id: 4, + id: '4', label: 'Team 2: Pure Hearts', ), ], ), MenuItem( - id: 5, + id: '5', label: 'Move to public', icon: VoicesAssets.icons.switchHorizontal.buildIcon(), showDivider: true, - enabled: false, + isEnabled: false, ), MenuItem( - id: 6, + id: '6', label: 'Delete', icon: VoicesAssets.icons.trash.buildIcon(), ), diff --git a/catalyst_voices/packages/internal/catalyst_voices_localization/lib/l10n/intl_en.arb b/catalyst_voices/packages/internal/catalyst_voices_localization/lib/l10n/intl_en.arb index 62c5d02f75c..f50bd18df7b 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_localization/lib/l10n/intl_en.arb +++ b/catalyst_voices/packages/internal/catalyst_voices_localization/lib/l10n/intl_en.arb @@ -1009,5 +1009,7 @@ "type": "int" } } - } + }, + "campaignCategories": "Campaign Categories", + "cardanoUseCases": "Cardano Use Cases" } \ No newline at end of file diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/keychain/vault_keychain.dart b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/keychain/vault_keychain.dart index 9a6ad8e7559..171fb17495f 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/keychain/vault_keychain.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/keychain/vault_keychain.dart @@ -127,7 +127,4 @@ final class VaultKeychain extends SecureStorageVault implements Keychain { @override String toString() => 'VaultKeychain[$id]'; - - @override - List get props => [id]; } diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/storage/vault/secure_storage_vault.dart b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/storage/vault/secure_storage_vault.dart index 00e91b49872..7bef14095d4 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/storage/vault/secure_storage_vault.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/storage/vault/secure_storage_vault.dart @@ -6,7 +6,6 @@ import 'package:catalyst_voices_services/src/crypto/crypto_service.dart'; import 'package:catalyst_voices_services/src/crypto/vault_crypto_service.dart'; import 'package:catalyst_voices_services/src/storage/storage_string_mixin.dart'; import 'package:catalyst_voices_services/src/storage/vault/vault.dart'; -import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; @@ -14,9 +13,8 @@ const _lockKey = 'LockKey'; /// Implementation of [Vault] that uses [FlutterSecureStorage] as /// facade for read/write operations. -base class SecureStorageVault - with StorageAsStringMixin, EquatableMixin - implements Vault { +base class SecureStorageVault with StorageAsStringMixin implements Vault { + @override final String id; @protected final FlutterSecureStorage secureStorage; @@ -171,6 +169,11 @@ base class SecureStorageVault } } + @override + String toString() { + return 'SecureStorageVault{id: $id}'; + } + /// Allows operation only when [isUnlocked] it true, otherwise returns null. /// /// Returns value assigned to [key]. May return null if not found for [key]. @@ -246,7 +249,4 @@ base class SecureStorageVault void _erase(Uint8List list) { list.fillRange(0, list.length, 0); } - - @override - List get props => [id]; } diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/storage/vault/vault.dart b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/storage/vault/vault.dart index 8d03c3ee084..67bbbb620d1 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/storage/vault/vault.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/storage/vault/vault.dart @@ -7,4 +7,6 @@ import 'package:catalyst_voices_services/src/storage/storage.dart'; /// /// In order to unlock [Vault] sufficient [LockFactor] have to be /// set via [unlock] that can unlock [LockFactor] from [setLock]. -abstract interface class Vault implements Storage, Lockable {} +abstract interface class Vault implements Storage, Lockable { + String get id; +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/test/src/keychain/vault_keychain_provider_test.dart b/catalyst_voices/packages/internal/catalyst_voices_services/test/src/keychain/vault_keychain_provider_test.dart index 8d66f091238..f8418f71e06 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/test/src/keychain/vault_keychain_provider_test.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/test/src/keychain/vault_keychain_provider_test.dart @@ -24,7 +24,10 @@ void main() { // Then expect(await provider.exists(id), isTrue); - expect([keychain], await provider.getAll()); + expect( + [keychain.id], + await provider.getAll().then((value) => value.map((e) => e.id)), + ); }); test('calling create twice on keychain will empty previous data', () async { diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/test/src/keychain/vault_keychain_test.dart b/catalyst_voices/packages/internal/catalyst_voices_services/test/src/keychain/vault_keychain_test.dart index ccfd397b5a9..6d4d4d542a9 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/test/src/keychain/vault_keychain_test.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/test/src/keychain/vault_keychain_test.dart @@ -68,7 +68,7 @@ void main() { ); }); - test('are equal when id is matching', () async { + test('are not equal when id is matching', () async { // Given final id = const Uuid().v4(); @@ -77,7 +77,7 @@ void main() { final vaultTwo = VaultKeychain(id: id); // Then - expect(vaultOne, equals(vaultTwo)); + expect(vaultOne, isNot(equals(vaultTwo))); }); test('metadata dates are in UTC', () async { diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/test/src/user/user_service_test.dart b/catalyst_voices/packages/internal/catalyst_voices_services/test/src/user/user_service_test.dart index 045abedef1e..c4aaecb3b41 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/test/src/user/user_service_test.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/test/src/user/user_service_test.dart @@ -29,7 +29,7 @@ void main() { // Then final currentKeychain = service.keychain; - expect(currentKeychain, keychain); + expect(currentKeychain?.id, keychain.id); }); test('using different keychain emits update in stream', () async { @@ -48,8 +48,8 @@ void main() { keychainStream, emitsInOrder([ isNull, - keychainOne, - keychainTwo, + predicate((e) => e.id == keychainOne.id), + predicate((e) => e.id == keychainTwo.id), isNull, ]), ); @@ -75,7 +75,7 @@ void main() { // Then final serviceKeychains = await service.keychains; - expect(serviceKeychains, keychains); + expect(serviceKeychains.map((e) => e.id), keychains.map((e) => e.id)); }); }); @@ -92,7 +92,7 @@ void main() { await service.useLastAccount(); // Then - expect(service.keychain, expectedKeychain); + expect(service.keychain?.id, expectedKeychain.id); }); test('use last account does nothing on clear instance', () async { diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_category.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_category.dart new file mode 100644 index 00000000000..fd2ca82d607 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_category.dart @@ -0,0 +1,14 @@ +import 'package:equatable/equatable.dart'; + +final class CampaignCategory extends Equatable { + final String id; + final String name; + + const CampaignCategory({ + required this.id, + required this.name, + }); + + @override + List get props => [id, name]; +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_section.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_section.dart new file mode 100644 index 00000000000..0e8a346e34f --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_section.dart @@ -0,0 +1,31 @@ +import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; +import 'package:equatable/equatable.dart'; + +final class CampaignSection extends Equatable implements MenuItem { + @override + final String id; + final CampaignCategory category; + final String title; + final String body; + + const CampaignSection({ + required this.id, + required this.category, + required this.title, + required this.body, + }); + + @override + String get label => category.name; + + @override + bool get isEnabled => true; + + @override + List get props => [ + id, + category, + title, + body, + ]; +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/catalyst_voices_view_models.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/catalyst_voices_view_models.dart index f745b4c9141..a51dc69220c 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/catalyst_voices_view_models.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/catalyst_voices_view_models.dart @@ -1,6 +1,10 @@ export 'authentication/authentication.dart'; +export 'campaign/campaign_category.dart'; +export 'campaign/campaign_section.dart'; export 'exception/localized_exception.dart'; export 'exception/localized_unknown_exception.dart'; +export 'menu/menu_item.dart'; +export 'menu/popup_menu_item.dart'; export 'navigation/sections_list_view_item.dart'; export 'navigation/sections_navigation.dart'; export 'proposal/comment.dart'; diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/menu/menu_item.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/menu/menu_item.dart new file mode 100644 index 00000000000..1481dba245e --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/menu/menu_item.dart @@ -0,0 +1,31 @@ +import 'package:equatable/equatable.dart'; + +abstract interface class MenuItem { + String get id; + + String get label; + + bool get isEnabled; +} + +base class BasicMenuItem extends Equatable implements MenuItem { + @override + final String id; + @override + final String label; + @override + final bool isEnabled; + + const BasicMenuItem({ + required this.id, + required this.label, + this.isEnabled = true, + }); + + @override + List get props => [ + id, + label, + isEnabled, + ]; +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/menu/popup_menu_item.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/menu/popup_menu_item.dart new file mode 100644 index 00000000000..9299e44e0f5 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/menu/popup_menu_item.dart @@ -0,0 +1,32 @@ +import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; +import 'package:flutter/widgets.dart'; + +abstract interface class PopupMenuItem implements MenuItem { + Widget? get icon; + + bool get showDivider; +} + +base class BasicPopupMenuItem extends BasicMenuItem implements PopupMenuItem { + @override + final Widget? icon; + + @override + final bool showDivider; + + const BasicPopupMenuItem({ + required super.id, + required super.label, + super.isEnabled, + this.icon, + this.showDivider = false, + }); + + @override + List get props => + super.props + + [ + icon, + showDivider, + ]; +} diff --git a/catalyst_voices/utilities/uikit_example/lib/examples/voices_menu_example.dart b/catalyst_voices/utilities/uikit_example/lib/examples/voices_menu_example.dart index 3dcfa8fb0fa..b85aa136885 100644 --- a/catalyst_voices/utilities/uikit_example/lib/examples/voices_menu_example.dart +++ b/catalyst_voices/utilities/uikit_example/lib/examples/voices_menu_example.dart @@ -71,28 +71,28 @@ class _MenuExample1 extends StatelessWidget { onTap: (menuItem) => debugPrint('Selected label: ${menuItem.label}'), menuItems: [ MenuItem( - id: 1, + id: '1', label: 'Rename', icon: VoicesAssets.icons.pencil.buildIcon(), ), SubMenuItem( - id: 4, + id: '4', label: 'Move Private Team', icon: VoicesAssets.icons.switchHorizontal.buildIcon(), - children: [ - MenuItem(id: 5, label: 'Team 1: The Vikings'), - MenuItem(id: 6, label: 'Team 2: Pure Hearts'), + children: const [ + MenuItem(id: '5', label: 'Team 1: The Vikings'), + MenuItem(id: '6', label: 'Team 2: Pure Hearts'), ], ), MenuItem( - id: 2, + id: '2', label: 'Move to public', icon: VoicesAssets.icons.switchHorizontal.buildIcon(), showDivider: true, - enabled: false, + isEnabled: false, ), MenuItem( - id: 3, + id: '3', label: 'Delete', icon: VoicesAssets.icons.trash.buildIcon(), ), @@ -118,27 +118,27 @@ class _MenuExample2 extends StatelessWidget { onTap: (menuItem) => debugPrint('Selected label: ${menuItem.label}'), menuItems: [ MenuItem( - id: 1, + id: '1', label: 'Rename', icon: VoicesAssets.icons.pencil.buildIcon(), ), SubMenuItem( - id: 4, + id: '4', label: 'Move Private Team', icon: VoicesAssets.icons.switchHorizontal.buildIcon(), - children: [ - MenuItem(id: 5, label: 'Team 1: The Vikings'), - MenuItem(id: 6, label: 'Team 2: Pure Hearts'), + children: const [ + MenuItem(id: '5', label: 'Team 1: The Vikings'), + MenuItem(id: '6', label: 'Team 2: Pure Hearts'), ], ), MenuItem( - id: 2, + id: '2', label: 'Move to public', icon: VoicesAssets.icons.switchHorizontal.buildIcon(), showDivider: true, ), MenuItem( - id: 3, + id: '3', label: 'Delete', icon: VoicesAssets.icons.trash.buildIcon(), ), From 531349df794dd1251b7599fad1057ced7cdeb145 Mon Sep 17 00:00:00 2001 From: Dominik Toton <166132265+dtscalac@users.noreply.github.com> Date: Mon, 25 Nov 2024 15:28:39 +0100 Subject: [PATCH 05/17] feat(cat-voices): autofocus password field to enable faster input (#1252) --- .../voices/lib/pages/account/unlock_keychain_dialog.dart | 1 + .../lib/widgets/text_field/voices_password_text_field.dart | 5 +++++ .../voices/lib/widgets/text_field/voices_text_field.dart | 5 +++++ 3 files changed, 11 insertions(+) diff --git a/catalyst_voices/apps/voices/lib/pages/account/unlock_keychain_dialog.dart b/catalyst_voices/apps/voices/lib/pages/account/unlock_keychain_dialog.dart index 2c3b3385e45..deb0a32412e 100644 --- a/catalyst_voices/apps/voices/lib/pages/account/unlock_keychain_dialog.dart +++ b/catalyst_voices/apps/voices/lib/pages/account/unlock_keychain_dialog.dart @@ -155,6 +155,7 @@ class _UnlockPassword extends StatelessWidget { Widget build(BuildContext context) { return VoicesPasswordTextField( controller: controller, + autofocus: true, decoration: VoicesTextFieldDecoration( labelText: context.l10n.unlockDialogHint, errorText: error?.message(context), diff --git a/catalyst_voices/apps/voices/lib/widgets/text_field/voices_password_text_field.dart b/catalyst_voices/apps/voices/lib/widgets/text_field/voices_password_text_field.dart index e7c20a7810b..190bfaa79b4 100644 --- a/catalyst_voices/apps/voices/lib/widgets/text_field/voices_password_text_field.dart +++ b/catalyst_voices/apps/voices/lib/widgets/text_field/voices_password_text_field.dart @@ -19,6 +19,9 @@ final class VoicesPasswordTextField extends StatelessWidget { /// Optional decoration. See [VoicesTextField] for more details. final VoicesTextFieldDecoration? decoration; + /// [VoicesTextField.autofocus]. + final bool autofocus; + const VoicesPasswordTextField({ super.key, this.controller, @@ -26,6 +29,7 @@ final class VoicesPasswordTextField extends StatelessWidget { this.onChanged, this.onSubmitted, this.decoration, + this.autofocus = false, }); @override @@ -33,6 +37,7 @@ final class VoicesPasswordTextField extends StatelessWidget { return VoicesTextField( controller: controller, keyboardType: TextInputType.visiblePassword, + autofocus: autofocus, obscureText: true, textInputAction: textInputAction, onChanged: onChanged, diff --git a/catalyst_voices/apps/voices/lib/widgets/text_field/voices_text_field.dart b/catalyst_voices/apps/voices/lib/widgets/text_field/voices_text_field.dart index 31220324918..e63a838bf80 100644 --- a/catalyst_voices/apps/voices/lib/widgets/text_field/voices_text_field.dart +++ b/catalyst_voices/apps/voices/lib/widgets/text_field/voices_text_field.dart @@ -32,6 +32,9 @@ class VoicesTextField extends StatefulWidget { /// [TextField.style] final TextStyle? style; + /// [TextField.autofocus] + final bool autofocus; + /// [TextField.obscureText] final bool obscureText; @@ -77,6 +80,7 @@ class VoicesTextField extends StatefulWidget { this.textInputAction, this.textCapitalization = TextCapitalization.none, this.style, + this.autofocus = false, this.obscureText = false, this.maxLength, this.maxLines = 1, @@ -176,6 +180,7 @@ class _VoicesTextFieldState extends State { resizableVertically: resizable, child: TextFormField( textAlignVertical: TextAlignVertical.top, + autofocus: widget.autofocus, expands: resizable, controller: _obtainController(), focusNode: widget.focusNode, From 3bf0ccf6cbc38359e888e53cb24ada753b0f2ebc Mon Sep 17 00:00:00 2001 From: Steven Johnson Date: Mon, 25 Nov 2024 23:03:34 +0700 Subject: [PATCH 06/17] feat(cat-gateway): Finliaze CIP36 Endpoint Cleanup (#1241) * fix: api endpoint draft Signed-off-by: bkioshn * fix: api health endpoint v1 Signed-off-by: bkioshn * fix: remove bad request from errorResponses Signed-off-by: bkioshn * fix: add bad req to get /registration Signed-off-by: bkioshn * fix: error logging Signed-off-by: bkioshn * fix: remove validation error Signed-off-by: bkioshn * fix: registration get error name Signed-off-by: bkioshn * chore:format Signed-off-by: bkioshn * fix: get json schema from openapi spec Signed-off-by: bkioshn * fix: move schema utils Signed-off-by: bkioshn * fix: optional field Signed-off-by: bkioshn * fix: config key Signed-off-by: bkioshn * fix: cat-gateway code gen Signed-off-by: bkioshn * fix: api name in cat-voice Signed-off-by: bkioshn * fix: cat-voice format Signed-off-by: bkioshn * chore: fix spacing Signed-off-by: bkioshn * chore: fix spacing Signed-off-by: bkioshn * chore: change tag config description * test: add test for default validator * fix: add spectral ruleset Signed-off-by: bkioshn * fix(cat-gateway): Sort the spelling words, and use latest deny.toml * fix(cat-gateway): Fix broken pre-push justfile target * docs(cat-gateway): cleanup * docs(cat-gateway): Fix API Groups and document them better * docs(cat-gateway): Add documentation to the health/inspection endpoint * docs(cat-gateway): Add descriptions for cardano/cip36/latest_registration/stake_addr * docs(cat-gateway): Document stake key hash and vote key endpoints for cardano * docs(cat-gateway): add documentation to config/frontend * docs(cat-gateway): Add api docs for frontend schema * docs(cat-gateway): Move legacy registration endpoints into the Legacy TAG. * docs(cat-gateway): Remaining documentable entities documented * fix: update openapi linter Signed-off-by: bkioshn * docs(cat-gateway): Add more constraints to parameters and json bodies * fix: openapi lint FUNCTION name Signed-off-by: bkioshn * fix: CIP36 example and description Signed-off-by: bkioshn * fix(cat-gateway): cleanup error handling, and add a global 429 response to all endpoints. * fix: config endpoint example, desc, and return Signed-off-by: bkioshn * chore: remove todo Signed-off-by: bkioshn * fix: move config object Signed-off-by: bkioshn * fix: move cip36 object Signed-off-by: bkioshn * docs(cat-gateway): Add missing headers to responses * docs(cat-gateway): Cleanup the rest of the documentation in the api * fix(cat-gateway): Fix OpenAPI linting and add autogenerated api file for dart. * refactor(cat-gateway): Better generalize the OpenAPI simple string type creation macro. * fix(cat-gateway): Add APIKey and CatToken auth to some endpoints. Add 401 and 403 common responses. * fix(cat-gateway): Add universal 422 response to all endpoints, and try and make all endpoint validation use it. * fix: add cardano stake address type Signed-off-by: bkioshn * fix(cat-gateway): stake address type Signed-off-by: bkioshn * fix(cat-gateway): Refactor the RBAC Token auth, so it's easier to maintain. * fix(cat-gateway): stake address name Signed-off-by: bkioshn * fix(cat-gateway): Add no auth and no-auth+rbac auth schemes * fix(cat-gateway): format + stake addr example Signed-off-by: bkioshn * fix(cat-gateway): code format * fix(cat-gateway): openapi spectral example rules Signed-off-by: bkioshn * fix(cat-gateway): Move legacy registration endpoint under Legacy Tag * fix(cat-gateway): Add Auth to all endpoints * fix(docs): Remove obsolete lint config file * fix(cat-gateway): Make config.toml match upstream * docs(docs): update project dictionary * feat(cat-gateway): add target to make it quick to check openapi lints locally * fix(cat-gateway): Remove reference to hermes * fix(cat-gateway): Add auth to rbac endpoints * docs(cat-gateway): Add full docs for v1/votes/plan/account-votes * docs(cat-gateway): Add example for ip address query argument * fix(cat-gateway): Define and abstract Ed25519 Public Keys as hex encoded parameters * fix(cat-gateway): Make sure string api types do not directly expose the internal string * fix(cat-gateway): Make conversion from a Ed25519 pub key hex value to a Verifyingkey infallible * fix(cat-gateway): Fix native asset response types * docs(cat-gateway): fix comments * fix(cat-gateway): Autogenerate flutter files * fix(cat-gateway): Exclude legacy endpoints from needing api examples * fix(cat-gateway): WIP improving cip36 endpoint docs * fix(docs): Make targets to re-check the generated schema easy. * fix: spectral ruleset for linting query params description * feat: parameter rule * fix: debug function * docs(cat-gateway): Make schema lint accept description inside a schema in a query parameter * fix(cat-gateway): remove debug logic from api docs lint * fix(cat-gateway): Don't put expanded program into git * Make error response comments consistent * test(cat-gateway): Add local operation to easily expand macros in the service code * fix(cat-gateway): CIP36 Structured endpoint * fix: speling * fix(rust): cleanup/normalize nonce validation * fix(rust): code format * Update catalyst-gateway/bin/src/service/common/types/cardano/cip19_shelley_address.rs Co-authored-by: bkioshn <35752733+bkioshn@users.noreply.github.com> * Update catalyst-gateway/bin/src/service/common/types/cardano/cip19_shelley_address.rs Co-authored-by: bkioshn <35752733+bkioshn@users.noreply.github.com> --------- Signed-off-by: bkioshn Co-authored-by: bkioshn Co-authored-by: bkioshn <35752733+bkioshn@users.noreply.github.com> Co-authored-by: Dominik Toton <166132265+dtscalac@users.noreply.github.com> Co-authored-by: Apisit Ritreungroj --- .config/dictionaries/project.dic | 3 +- catalyst-gateway/.gitignore | 3 +- catalyst-gateway/Justfile | 21 +- catalyst-gateway/bin/Cargo.toml | 1 + catalyst-gateway/bin/Justfile | 12 + .../src/service/api/cardano/cip36/endpoint.rs | 32 ++ .../bin/src/service/api/cardano/cip36/mod.rs | 140 +++++++ .../{cip36.rs => cip36/old_endpoint.rs} | 0 .../src/service/api/cardano/cip36/response.rs | 166 ++++++++ .../bin/src/service/api/cardano/mod.rs | 105 +---- .../api/cardano/rbac/chain_root_get.rs | 4 +- .../service/api/cardano/staking/assets_get.rs | 5 +- .../src/service/api/cardano/staking/mod.rs | 2 +- catalyst-gateway/bin/src/service/api/mod.rs | 2 +- .../bin/src/service/common/auth/api_key.rs | 19 +- .../service/common/objects/cardano/cip36.rs | 3 + .../service/common/objects/cardano/hash.rs | 4 +- .../src/service/common/objects/cardano/mod.rs | 8 +- .../objects/cardano/registration_info.rs | 8 +- .../common/objects/cardano/sync_state.rs | 2 +- .../src/service/common/objects/generic/mod.rs | 3 + .../common/objects/generic/pagination.rs | 48 +++ .../bin/src/service/common/objects/mod.rs | 1 + .../common/responses/code_401_unauthorized.rs | 23 +- .../common/responses/code_403_forbidden.rs | 7 +- .../code_422_unprocessable_content.rs | 90 ++--- .../responses/code_429_too_many_requests.rs | 6 +- .../code_500_internal_server_error.rs | 6 +- .../responses/code_503_service_unavailable.rs | 9 +- .../bin/src/service/common/responses/mod.rs | 2 +- .../common/types/cardano/asset_value.rs | 2 + .../types/cardano/cip19_shelley_address.rs | 144 +++++++ .../{address.rs => cip19_stake_address.rs} | 70 +++- .../service/common/types/cardano/hash28.rs | 11 +- .../src/service/common/types/cardano/mod.rs | 7 +- .../src/service/common/types/cardano/nonce.rs | 126 ++++++ .../common/types/cardano/query/as_at.rs | 169 ++++++++ .../service/common/types/cardano/query/mod.rs | 9 + .../types/cardano/query/stake_or_voter.rs | 200 ++++++++++ .../service/common/types/cardano/slot_no.rs | 131 +++++++ .../service/common/types/cardano/txn_index.rs | 147 +++++++ .../types/generic/ed25519_public_key.rs | 60 ++- .../service/common/types/generic/error_msg.rs | 79 ++++ .../src/service/common/types/generic/mod.rs | 2 + .../service/common/types/generic/query/mod.rs | 12 + .../common/types/generic/query/pagination.rs | 360 ++++++++++++++++++ .../src/service/common/types/string_types.rs | 10 +- .../bin/src/service/utilities/mod.rs | 23 +- catalyst-gateway/bin/src/settings/mod.rs | 7 + catalyst-gateway/rustfmt.toml | 2 +- catalyst-gateway/tests/Earthfile | 2 +- .../.spectral.yml} | 26 +- .../openapi-v3.0-lints/functions/debug.js | 28 ++ .../functions/description-required.js | 117 ++++++ .../api/cat-gateway/stoplight_template.html | 7 +- 55 files changed, 2238 insertions(+), 248 deletions(-) create mode 100644 catalyst-gateway/bin/Justfile create mode 100644 catalyst-gateway/bin/src/service/api/cardano/cip36/endpoint.rs create mode 100644 catalyst-gateway/bin/src/service/api/cardano/cip36/mod.rs rename catalyst-gateway/bin/src/service/api/cardano/{cip36.rs => cip36/old_endpoint.rs} (100%) create mode 100644 catalyst-gateway/bin/src/service/api/cardano/cip36/response.rs create mode 100644 catalyst-gateway/bin/src/service/common/objects/generic/mod.rs create mode 100644 catalyst-gateway/bin/src/service/common/objects/generic/pagination.rs create mode 100644 catalyst-gateway/bin/src/service/common/types/cardano/cip19_shelley_address.rs rename catalyst-gateway/bin/src/service/common/types/cardano/{address.rs => cip19_stake_address.rs} (66%) create mode 100644 catalyst-gateway/bin/src/service/common/types/cardano/nonce.rs create mode 100644 catalyst-gateway/bin/src/service/common/types/cardano/query/as_at.rs create mode 100644 catalyst-gateway/bin/src/service/common/types/cardano/query/mod.rs create mode 100644 catalyst-gateway/bin/src/service/common/types/cardano/query/stake_or_voter.rs create mode 100644 catalyst-gateway/bin/src/service/common/types/cardano/slot_no.rs create mode 100644 catalyst-gateway/bin/src/service/common/types/cardano/txn_index.rs create mode 100644 catalyst-gateway/bin/src/service/common/types/generic/error_msg.rs create mode 100644 catalyst-gateway/bin/src/service/common/types/generic/query/mod.rs create mode 100644 catalyst-gateway/bin/src/service/common/types/generic/query/pagination.rs rename catalyst-gateway/tests/{.oapi-v3.spectral.yml => openapi-v3.0-lints/.spectral.yml} (93%) create mode 100644 catalyst-gateway/tests/openapi-v3.0-lints/functions/debug.js create mode 100644 catalyst-gateway/tests/openapi-v3.0-lints/functions/description-required.js diff --git a/.config/dictionaries/project.dic b/.config/dictionaries/project.dic index 9288b68c6b9..a61c56ea5a4 100644 --- a/.config/dictionaries/project.dic +++ b/.config/dictionaries/project.dic @@ -14,6 +14,7 @@ Arbritrary ARCHS ARGB Arissara +asat asmjs asyncio asyncpg @@ -241,6 +242,7 @@ rustflags rustfmt rustls rxdart +ryszard-schossler saibatizoku Schemathesis Scripthash @@ -286,7 +288,6 @@ trailings TXNZD txos Typer -ryszard-schossler unawaited unchunk Unlogged diff --git a/catalyst-gateway/.gitignore b/catalyst-gateway/.gitignore index fea35a6a809..699900d6cce 100644 --- a/catalyst-gateway/.gitignore +++ b/catalyst-gateway/.gitignore @@ -13,4 +13,5 @@ target/ # Build artifacts cat-gateway.coverage.info cat-gateway.junit-report.xml -cat-gateway-api.* \ No newline at end of file +cat-gateway-api.* +expanded.rs \ No newline at end of file diff --git a/catalyst-gateway/Justfile b/catalyst-gateway/Justfile index 9d16e79ea20..32ae8fa693f 100644 --- a/catalyst-gateway/Justfile +++ b/catalyst-gateway/Justfile @@ -57,7 +57,22 @@ run-cat-gateway-mainnet: build-cat-gateway RUST_LOG="error,cat_gateway=debug,cardano_chain_follower=debug,mithril-client=debug" \ ./target/release/cat-gateway run --log-level debug -# Do the minimal work needed to test the schema generated by cat-gateway -quick-schema-lint: build-cat-gateway +# expand all macros and produce a single unified source file. +expand-macros: + just bin/expand-macros + +# Generate the current openapi schema file locally. +generate-openapi-schema: build-cat-gateway ./target/release/cat-gateway docs cat-gateway-api.json - docker run --rm -it -v $(pwd):/tmp stoplight/spectral:latest lint --ruleset "/tmp/tests/.oapi-v3.spectral.yml" "/tmp/cat-gateway-api.json" \ No newline at end of file + +# Lint an openapi schema that has already been generated +lint-generated-schema: + docker run --rm -it -v $(pwd):/tmp stoplight/spectral:latest lint --ruleset "/tmp/tests/openapi-v3.0-lints/.spectral.yml" "/tmp/cat-gateway-api.json" + +# Lint an openapi schema that has already been generated locally. +# Make sure before running this command, you have installed "spectral" locally. +lint-generated-schema-local: + spectral lint --ruleset "./tests/openapi-v3.0-lints/.spectral.yml" "cat-gateway-api.json" + +# Do the minimal work needed to test the schema generated by cat-gateway +quick-schema-lint: generate-openapi-schema lint-generated-schema diff --git a/catalyst-gateway/bin/Cargo.toml b/catalyst-gateway/bin/Cargo.toml index ca17f018695..2eaedb86a82 100644 --- a/catalyst-gateway/bin/Cargo.toml +++ b/catalyst-gateway/bin/Cargo.toml @@ -92,6 +92,7 @@ der-parser = "9.0.0" jsonschema = "0.26.1" bech32 = "0.11.0" const_format = "0.2.33" +regex = "1.11.1" [dev-dependencies] proptest = "1.5.0" diff --git a/catalyst-gateway/bin/Justfile b/catalyst-gateway/bin/Justfile new file mode 100644 index 00000000000..1c02f5933f6 --- /dev/null +++ b/catalyst-gateway/bin/Justfile @@ -0,0 +1,12 @@ +# use with https://github.com/casey/just +# +# Developer convenience functions + +# cspell: words prereqs, commitlog, rustls, nocapture + +default: + @just --list --unsorted + +# expand all macros and produce a single unified source file. +expand-macros: + cargo expand --release --bin cat-gateway > ../expanded.rs diff --git a/catalyst-gateway/bin/src/service/api/cardano/cip36/endpoint.rs b/catalyst-gateway/bin/src/service/api/cardano/cip36/endpoint.rs new file mode 100644 index 00000000000..cfd5d5f2a51 --- /dev/null +++ b/catalyst-gateway/bin/src/service/api/cardano/cip36/endpoint.rs @@ -0,0 +1,32 @@ +//! Implementation of the GET `/cardano/cip36` endpoint + +use std::time::Duration; + +use poem::http::HeaderMap; +use tokio::time::sleep; + +use super::{ + cardano::{self}, + response, NoneOrRBAC, SlotNo, +}; +use crate::service::common::{self}; + +/// Process the endpoint operation +pub(crate) async fn cip36_registrations( + _lookup: Option, _asat: Option, + _page: common::types::generic::query::pagination::Page, + _limit: common::types::generic::query::pagination::Limit, _auth: NoneOrRBAC, + _headers: &HeaderMap, +) -> response::AllRegistration { + // Dummy sleep, remove it + sleep(Duration::from_millis(1)).await; + + // Todo: refactor the below into a single operation here. + + // If _asat is None, then get the latest slot number from the chain follower and use that. + // If _for is not defined, use the stake addresses defined for Role0 in the _auth + // parameter. _auth not yet implemented, so put placeholder for that, and return not + // found until _auth is implemented. + + response::Cip36Registration::NotFound.into() +} diff --git a/catalyst-gateway/bin/src/service/api/cardano/cip36/mod.rs b/catalyst-gateway/bin/src/service/api/cardano/cip36/mod.rs new file mode 100644 index 00000000000..549dc776443 --- /dev/null +++ b/catalyst-gateway/bin/src/service/api/cardano/cip36/mod.rs @@ -0,0 +1,140 @@ +//! CIP36 Registration Endpoints + +use ed25519_dalek::VerifyingKey; +use poem::http::{HeaderMap, StatusCode}; +use poem_openapi::{param::Query, OpenApi}; + +use self::cardano::slot_no::SlotNo; +use super::Ed25519HexEncodedPublicKey; +use crate::service::common::{ + self, + auth::none_or_rbac::NoneOrRBAC, + tags::ApiTags, + types::cardano::{self}, +}; + +pub(crate) mod endpoint; +pub(crate) mod old_endpoint; +pub(crate) mod response; + +/// Cardano Staking API Endpoints +pub(crate) struct Api; + +#[OpenApi(tag = "ApiTags::Cardano")] +impl Api { + /// CIP36 registrations. + /// + /// This endpoint gets the latest registration given either the voting key, stake + /// address, stake public key or the auth token. + /// + /// Registration can be the latest to date, or at a particular date-time or slot + /// number. + // Required To be able to look up for: + // 1. Voting Public Key + // 2. Cip-19 stake address + // 3. All - Hidden option and would need a hidden api key header (used to create a snapshot + // replacement.) + // 4. Stake addresses associated with current Role0 registration (if none of the above + // provided). + // If none of the above provided, return not found. + #[oai( + path = "/draft/cardano/registration/cip36", + method = "get", + operation_id = "cardanoRegistrationCip36" + )] + async fn get_registration( + &self, lookup: Query>, + asat: Query>, + page: Query>, + limit: Query>, + /// No Authorization required, but Token permitted. + auth: NoneOrRBAC, + /// Headers, used if the query is requesting ALL to determine if the secret API + /// Key is also defined. + headers: &HeaderMap, + ) -> response::AllRegistration { + // Special validation for the `lookup` parameter. + // If the parameter is ALL, BUT we do not have a valid API Key, just report the parameter + // is invalid. + if let Some(lookup) = lookup.0.clone() { + if lookup.is_all(headers).is_err() { + return response::AllRegistration::unprocessable_content(vec![ + poem::Error::from_string( + "Invalid Stake Address or Voter Key", + StatusCode::UNPROCESSABLE_ENTITY, + ), + ]); + } + } + + endpoint::cip36_registrations( + lookup.0, + SlotNo::into_option(asat.0), + page.0.unwrap_or_default(), + limit.0.unwrap_or_default(), + auth, + headers, + ) + .await + } + + /// Get latest CIP36 registrations from stake address. + /// + /// This endpoint gets the latest registration given a stake address. + #[oai( + path = "/draft/cardano/cip36/latest_registration/stake_addr", + method = "get", + operation_id = "latestRegistrationGivenStakeAddr" + )] + async fn latest_registration_cip36_given_stake_addr( + &self, + /// Stake Public Key to find the latest registration for. + stake_pub_key: Query, // Validation provided by type. + /// No Authorization required, but Token permitted. + _auth: NoneOrRBAC, + ) -> old_endpoint::SingleRegistrationResponse { + let hex_key = stake_pub_key.0; + let pub_key: VerifyingKey = hex_key.into(); + + old_endpoint::get_latest_registration_from_stake_addr(&pub_key, true).await + } + + /// Get latest CIP36 registrations from a stake key hash. + /// + /// This endpoint gets the latest registration given a stake key hash. + #[oai( + path = "/draft/cardano/cip36/latest_registration/stake_key_hash", + method = "get", + operation_id = "latestRegistrationGivenStakeHash" + )] + async fn latest_registration_cip36_given_stake_key_hash( + &self, + /// Stake Key Hash to find the latest registration for. + #[oai(validator(max_length = 66, min_length = 0, pattern = "[0-9a-f]"))] + stake_key_hash: Query, + /// No Authorization required, but Token permitted. + _auth: NoneOrRBAC, + ) -> old_endpoint::SingleRegistrationResponse { + old_endpoint::get_latest_registration_from_stake_key_hash(stake_key_hash.0, true).await + } + + /// Get latest CIP36 registrations from voting key. + /// + /// This endpoint returns the list of stake address registrations currently associated + /// with a given voting key. + #[oai( + path = "/draft/cardano/cip36/latest_registration/vote_key", + method = "get", + operation_id = "latestRegistrationGivenVoteKey" + )] + async fn latest_registration_cip36_given_vote_key( + &self, + /// Voting Key to find CIP36 registrations for. + #[oai(validator(max_length = 66, min_length = 66, pattern = "0x[0-9a-f]"))] + vote_key: Query, + /// No Authorization required, but Token permitted. + _auth: NoneOrRBAC, + ) -> old_endpoint::MultipleRegistrationResponse { + old_endpoint::get_associated_vote_key_registrations(vote_key.0, true).await + } +} diff --git a/catalyst-gateway/bin/src/service/api/cardano/cip36.rs b/catalyst-gateway/bin/src/service/api/cardano/cip36/old_endpoint.rs similarity index 100% rename from catalyst-gateway/bin/src/service/api/cardano/cip36.rs rename to catalyst-gateway/bin/src/service/api/cardano/cip36/old_endpoint.rs diff --git a/catalyst-gateway/bin/src/service/api/cardano/cip36/response.rs b/catalyst-gateway/bin/src/service/api/cardano/cip36/response.rs new file mode 100644 index 00000000000..fc9d0b5411a --- /dev/null +++ b/catalyst-gateway/bin/src/service/api/cardano/cip36/response.rs @@ -0,0 +1,166 @@ +//! Cip36 Registration Query Endpoint Response +use poem_openapi::{payload::Json, types::Example, ApiResponse, Object}; + +use crate::service::common; + +// ToDo: The examples of this response should be taken from representative data from a +// response generated on pre-prod. + +/// Endpoint responses. +#[derive(ApiResponse)] +#[allow(dead_code)] // TODO: Remove once endpoint fully implemented +pub(crate) enum Cip36Registration { + /// All CIP36 registrations associated with the same Voting Key. + #[oai(status = 200)] + Ok(Json), + /// No valid registration. + #[oai(status = 404)] + NotFound, +} + +/// All responses to a cip36 registration query +pub(crate) type AllRegistration = common::responses::WithErrorResponses; + +/// List of CIP36 Registration Data as found on-chain. +#[derive(Object)] +#[oai(example = true)] +pub(crate) struct Cip36RegistrationList { + /// The Slot the Registrations are valid up until. + /// + /// Any registrations that occurred after this Slot are not included in the list. + /// Errors are reported only if they fall between the last valid registration and this + /// slot number. + /// Earlier errors are never reported. + slot: common::types::cardano::slot_no::SlotNo, + /// List of registrations associated with the query. + #[oai(validator(max_items = "100"))] + voting_key: Vec, + /// List of latest invalid registrations that were found, for the requested filter. + #[oai(skip_serializing_if_is_empty, validator(max_items = "10"))] + invalid: Vec, + /// Current Page + page: common::objects::generic::pagination::CurrentPage, +} + +impl Example for Cip36RegistrationList { + fn example() -> Self { + Self { + slot: (common::types::cardano::slot_no::EXAMPLE + 635).into(), + voting_key: vec![Cip36RegistrationsForVotingPublicKey::example()], + invalid: vec![Cip36Details::invalid_example()], + page: common::objects::generic::pagination::CurrentPage::example(), + } + } +} + +/// List of CIP36 Registration Data for a Voting Key. +#[derive(Object)] +#[oai(example = true)] +pub(crate) struct Cip36RegistrationsForVotingPublicKey { + /// Voting Public Key + pub vote_pub_key: common::types::generic::ed25519_public_key::Ed25519HexEncodedPublicKey, + /// List of Registrations associated with this Voting Key + #[oai(validator(max_items = "100"))] + pub registrations: Vec, +} + +impl Example for Cip36RegistrationsForVotingPublicKey { + fn example() -> Self { + Cip36RegistrationsForVotingPublicKey { + vote_pub_key: + common::types::generic::ed25519_public_key::Ed25519HexEncodedPublicKey::example(), + registrations: vec![Cip36Details::example()], + } + } +} + +/// CIP36 Registration Data as found on-chain. +#[derive(Object)] +#[oai(example = true)] +pub(crate) struct Cip36Details { + /// Blocks Slot Number that the registration certificate is in. + pub slot_no: common::types::cardano::slot_no::SlotNo, + /// Full Stake Address (not hashed, 32 byte ED25519 Public key). + #[oai(skip_serializing_if_is_none)] + pub stake_pub_key: + Option, + /// Voting Public Key (Ed25519 Public key). + #[oai(skip_serializing_if_is_none)] + pub vote_pub_key: + Option, + #[allow(clippy::missing_docs_in_private_items)] // Type is pre documented. + #[oai(skip_serializing_if_is_none)] + pub nonce: Option, + #[allow(clippy::missing_docs_in_private_items)] // Type is pre documented. + #[oai(skip_serializing_if_is_none)] + pub txn: Option, + /// Cardano Cip-19 Formatted Shelley Payment Address. + #[oai(skip_serializing_if_is_none)] + pub payment_address: Option, + /// If the payment address is a script, then it can not be payed rewards. + #[oai(default = "is_payable_default")] + pub is_payable: bool, + /// If this field is set, then the registration was in CIP15 format. + #[oai(default = "cip15_default")] + pub cip15: bool, + /// If there are errors with this registration, they are listed here. + /// This field is *NEVER* returned for a valid registration. + #[oai( + default = "Vec::::new", + skip_serializing_if_is_empty, + validator(max_items = "10") + )] + pub errors: Vec, +} + +/// Is the payment address payable by catalyst. +fn is_payable_default() -> bool { + true +} + +/// Is the registration using CIP15 format. +fn cip15_default() -> bool { + false +} + +impl Example for Cip36Details { + /// Example of a valid registration + fn example() -> Self { + Self { + slot_no: common::types::cardano::slot_no::SlotNo::example(), + stake_pub_key: Some( + common::types::generic::ed25519_public_key::Ed25519HexEncodedPublicKey::examples(0), + ), + vote_pub_key: Some( + common::types::generic::ed25519_public_key::Ed25519HexEncodedPublicKey::example(), + ), + nonce: Some(common::types::cardano::nonce::Nonce::example()), + txn: Some(common::types::cardano::txn_index::TxnIndex::example()), + payment_address: Some( + common::types::cardano::cip19_shelley_address::Cip19ShelleyAddress::example(), + ), + is_payable: true, + cip15: false, + errors: Vec::::new(), + } + } +} + +impl Cip36Details { + /// Example of an invalid registration + fn invalid_example() -> Self { + Self { + slot_no: (common::types::cardano::slot_no::EXAMPLE + 135).into(), + stake_pub_key: None, + vote_pub_key: Some( + common::types::generic::ed25519_public_key::Ed25519HexEncodedPublicKey::example(), + ), + nonce: Some((common::types::cardano::nonce::EXAMPLE + 97).into()), + txn: Some(common::types::cardano::txn_index::TxnIndex::example()), + payment_address: None, + is_payable: false, + cip15: true, + errors: vec!["Stake Public Key is required".into()], + } + } +} diff --git a/catalyst-gateway/bin/src/service/api/cardano/mod.rs b/catalyst-gateway/bin/src/service/api/cardano/mod.rs index 059611067c0..6866e65b051 100644 --- a/catalyst-gateway/bin/src/service/api/cardano/mod.rs +++ b/catalyst-gateway/bin/src/service/api/cardano/mod.rs @@ -1,10 +1,9 @@ //! Cardano API endpoints -use ed25519_dalek::VerifyingKey; use poem_openapi::{ param::{Path, Query}, OpenApi, }; -use types::{DateTime, SlotNumber}; +use types::DateTime; use crate::service::{ common::{ @@ -12,17 +11,17 @@ use crate::service::{ objects::cardano::network::Network, tags::ApiTags, types::{ - cardano::address::Cip19StakeAddress, + cardano::cip19_stake_address::Cip19StakeAddress, generic::ed25519_public_key::Ed25519HexEncodedPublicKey, }, }, utilities::middleware::schema_validation::schema_version_validation, }; -mod cip36; +pub(crate) mod cip36; mod date_time_to_slot_number_get; mod rbac; -mod registration_get; +// mod registration_get; pub(crate) mod staking; mod sync_state_get; pub(crate) mod types; @@ -32,40 +31,6 @@ pub(crate) struct Api; #[OpenApi(tag = "ApiTags::Cardano")] impl Api { - /// Get registration info. - /// - /// This endpoint returns the registration info followed by the [CIP-36](https://cips.cardano.org/cip/CIP-36/) to the - /// corresponded user's stake address. - #[oai( - path = "/draft/cardano/registration/:stake_address", - method = "get", - operation_id = "registrationGet", - transform = "schema_version_validation" - )] - async fn registration_get( - &self, - /// The stake address of the user. - /// Should be a valid Bech32 encoded address followed by the https://cips.cardano.org/cip/CIP-19/#stake-addresses. - stake_address: Path, - /// Cardano network type. - /// If omitted network type is identified from the stake address. - /// If specified it must be correspondent to the network type encoded in the stake - /// address. - /// As `preprod` and `preview` network types in the stake address encoded as a - /// `testnet`, to specify `preprod` or `preview` network type use this - /// query parameter. - network: Query>, - /// Slot number at which the staked ADA amount should be calculated. - /// If omitted latest slot number is used. - // TODO(bkioshn): https://github.com/input-output-hk/catalyst-voices/issues/239 - #[oai(validator(minimum(value = "0"), maximum(value = "9223372036854775807")))] - slot_number: Query>, - /// No Authorization required, but Token permitted. - _auth: NoneOrRBAC, - ) -> registration_get::AllResponses { - registration_get::endpoint(stake_address.0, network.0, slot_number.0).await - } - /// Get Cardano follower's sync state. /// /// This endpoint returns the current cardano follower's sync state info. @@ -116,66 +81,6 @@ impl Api { date_time_to_slot_number_get::endpoint(date_time.0, network.0).await } - /// Get latest CIP36 registrations from stake address. - /// - /// This endpoint gets the latest registration given a stake address. - #[oai( - path = "/draft/cardano/cip36/latest_registration/stake_addr", - method = "get", - operation_id = "latestRegistrationGivenStakeAddr" - )] - async fn latest_registration_cip36_given_stake_addr( - &self, - /// Stake Public Key to find the latest registration for. - stake_pub_key: Query, // Validation provided by type. - /// No Authorization required, but Token permitted. - _auth: NoneOrRBAC, - ) -> cip36::SingleRegistrationResponse { - let hex_key = stake_pub_key.0; - let pub_key: VerifyingKey = hex_key.into(); - - cip36::get_latest_registration_from_stake_addr(&pub_key, true).await - } - - /// Get latest CIP36 registrations from a stake key hash. - /// - /// This endpoint gets the latest registration given a stake key hash. - #[oai( - path = "/draft/cardano/cip36/latest_registration/stake_key_hash", - method = "get", - operation_id = "latestRegistrationGivenStakeHash" - )] - async fn latest_registration_cip36_given_stake_key_hash( - &self, - /// Stake Key Hash to find the latest registration for. - #[oai(validator(max_length = 66, min_length = 0, pattern = "[0-9a-f]"))] - stake_key_hash: Query, - /// No Authorization required, but Token permitted. - _auth: NoneOrRBAC, - ) -> cip36::SingleRegistrationResponse { - cip36::get_latest_registration_from_stake_key_hash(stake_key_hash.0, true).await - } - - /// Get latest CIP36 registrations from voting key. - /// - /// This endpoint returns the list of stake address registrations currently associated - /// with a given voting key. - #[oai( - path = "/draft/cardano/cip36/latest_registration/vote_key", - method = "get", - operation_id = "latestRegistrationGivenVoteKey" - )] - async fn latest_registration_cip36_given_vote_key( - &self, - /// Voting Key to find CIP36 registrations for. - #[oai(validator(max_length = 66, min_length = 66, pattern = "0x[0-9a-f]"))] - vote_key: Query, - /// No Authorization required, but Token permitted. - _auth: NoneOrRBAC, - ) -> cip36::MultipleRegistrationResponse { - cip36::get_associated_vote_key_registrations(vote_key.0, true).await - } - #[oai( path = "/draft/rbac/chain_root/:stake_address", method = "get", @@ -234,4 +139,4 @@ impl Api { } /// Cardano API Endpoints -pub(crate) type CardanoApi = (Api, staking::Api); +pub(crate) type CardanoApi = (Api, staking::Api, cip36::Api); diff --git a/catalyst-gateway/bin/src/service/api/cardano/rbac/chain_root_get.rs b/catalyst-gateway/bin/src/service/api/cardano/rbac/chain_root_get.rs index 2769b1c2ed7..42e7de091b0 100644 --- a/catalyst-gateway/bin/src/service/api/cardano/rbac/chain_root_get.rs +++ b/catalyst-gateway/bin/src/service/api/cardano/rbac/chain_root_get.rs @@ -12,7 +12,9 @@ use crate::{ }, service::common::{ responses::WithErrorResponses, - types::{cardano::address::Cip19StakeAddress, headers::retry_after::RetryAfterOption}, + types::{ + cardano::cip19_stake_address::Cip19StakeAddress, headers::retry_after::RetryAfterOption, + }, }, }; diff --git a/catalyst-gateway/bin/src/service/api/cardano/staking/assets_get.rs b/catalyst-gateway/bin/src/service/api/cardano/staking/assets_get.rs index d1d2b72126e..cd0d2d04e37 100644 --- a/catalyst-gateway/bin/src/service/api/cardano/staking/assets_get.rs +++ b/catalyst-gateway/bin/src/service/api/cardano/staking/assets_get.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use anyhow::anyhow; use futures::StreamExt; +use pallas::ledger::addresses::StakeAddress; use poem_openapi::{payload::Json, ApiResponse}; use super::SlotNumber; @@ -26,7 +27,7 @@ use crate::{ stake_info::{FullStakeInfo, StakeInfo, StakedNativeTokenInfo}, }, responses::WithErrorResponses, - types::cardano::address::Cip19StakeAddress, + types::cardano::cip19_stake_address::Cip19StakeAddress, }, }; @@ -114,7 +115,7 @@ async fn calculate_stake_info( anyhow::bail!("Failed to acquire db session"); }; - let address = stake_address.to_stake_address()?; + let address: StakeAddress = stake_address.try_into()?; let stake_address_bytes = address.payload().as_hash().to_vec(); let mut txos_by_txn = get_txo_by_txn(&session, stake_address_bytes.clone(), slot_num).await?; diff --git a/catalyst-gateway/bin/src/service/api/cardano/staking/mod.rs b/catalyst-gateway/bin/src/service/api/cardano/staking/mod.rs index 7e3d9daf6ae..704b4d63264 100644 --- a/catalyst-gateway/bin/src/service/api/cardano/staking/mod.rs +++ b/catalyst-gateway/bin/src/service/api/cardano/staking/mod.rs @@ -9,7 +9,7 @@ use super::types::SlotNumber; use crate::service::{ common::{ auth::none_or_rbac::NoneOrRBAC, objects::cardano::network::Network, tags::ApiTags, - types::cardano::address::Cip19StakeAddress, + types::cardano::cip19_stake_address::Cip19StakeAddress, }, utilities::middleware::schema_validation::schema_version_validation, }; diff --git a/catalyst-gateway/bin/src/service/api/mod.rs b/catalyst-gateway/bin/src/service/api/mod.rs index 3d1e75f9314..0df680295f7 100644 --- a/catalyst-gateway/bin/src/service/api/mod.rs +++ b/catalyst-gateway/bin/src/service/api/mod.rs @@ -54,7 +54,7 @@ pub(crate) fn mk_api() -> OpenApiService<(HealthApi, CardanoApi, ConfigApi, Lega let mut service = OpenApiService::new( ( HealthApi, - (cardano::Api, cardano::staking::Api), + (cardano::Api, cardano::staking::Api, cardano::cip36::Api), ConfigApi, (legacy::RegistrationApi, legacy::V0Api, legacy::V1Api), ), diff --git a/catalyst-gateway/bin/src/service/common/auth/api_key.rs b/catalyst-gateway/bin/src/service/common/auth/api_key.rs index 7264e7906aa..4abc18efb98 100644 --- a/catalyst-gateway/bin/src/service/common/auth/api_key.rs +++ b/catalyst-gateway/bin/src/service/common/auth/api_key.rs @@ -4,16 +4,20 @@ //! //! It is NOT to be used on any endpoint intended to be publicly facing. -use poem::Request; +use anyhow::{bail, Result}; +use poem::{http::HeaderMap, Request}; use poem_openapi::{auth::ApiKey, SecurityScheme}; use crate::settings::Settings; +/// The header name that holds the API Key +const API_KEY_HEADER: &str = "X-API-Key"; + /// `ApiKey` authorization for Internal Endpoints #[derive(SecurityScheme)] #[oai( ty = "api_key", - key_name = "X-API-Key", + key_name = "X-API-Key", // MUST match the above constant. key_in = "header", checker = "api_checker" )] @@ -29,3 +33,14 @@ async fn api_checker(_req: &Request, api_key: ApiKey) -> Option { None } } + +/// Check if the API Key is correctly set. +/// Returns an error if it is not. +pub(crate) fn check_api_key(headers: &HeaderMap) -> Result<()> { + if let Some(key) = headers.get(API_KEY_HEADER) { + if Settings::check_internal_api_key(key.to_str()?) { + return Ok(()); + } + } + bail!("Invalid API Key"); +} diff --git a/catalyst-gateway/bin/src/service/common/objects/cardano/cip36.rs b/catalyst-gateway/bin/src/service/common/objects/cardano/cip36.rs index 9144a43a623..707d1e5631b 100644 --- a/catalyst-gateway/bin/src/service/common/objects/cardano/cip36.rs +++ b/catalyst-gateway/bin/src/service/common/objects/cardano/cip36.rs @@ -1,5 +1,8 @@ //! CIP36 object +// TODO: This is NOT common, remove it once the rationalized endpoint is implemented. +// Retained to keep the existing code from breaking only. + use poem_openapi::{types::Example, Object}; use crate::service::common::types::generic::ed25519_public_key::Ed25519HexEncodedPublicKey; diff --git a/catalyst-gateway/bin/src/service/common/objects/cardano/hash.rs b/catalyst-gateway/bin/src/service/common/objects/cardano/hash.rs index 295c4409a40..d7cc7daae6f 100644 --- a/catalyst-gateway/bin/src/service/common/objects/cardano/hash.rs +++ b/catalyst-gateway/bin/src/service/common/objects/cardano/hash.rs @@ -5,7 +5,7 @@ use poem_openapi::{ types::{ParseError, ParseFromJSON, ParseFromParameter, ParseResult, ToJSON, Type}, }; -use crate::service::utilities::to_hex_with_prefix; +use crate::service::utilities::as_hex_string; /// Cardano Blake2b256 hash encoded in hex. #[derive(Debug)] @@ -91,6 +91,6 @@ impl ParseFromJSON for Hash { impl ToJSON for Hash { fn to_json(&self) -> Option { - Some(serde_json::Value::String(to_hex_with_prefix(&self.0))) + Some(serde_json::Value::String(as_hex_string(&self.0))) } } diff --git a/catalyst-gateway/bin/src/service/common/objects/cardano/mod.rs b/catalyst-gateway/bin/src/service/common/objects/cardano/mod.rs index 63159b676c9..391e5e16e4b 100644 --- a/catalyst-gateway/bin/src/service/common/objects/cardano/mod.rs +++ b/catalyst-gateway/bin/src/service/common/objects/cardano/mod.rs @@ -1,6 +1,10 @@ -//! Defines API schemas of Cardano types. +//! Defines API schemas of Cardano Objects. +//! +//! These Objects MUST be used in multiple places for multiple things to be considered +//! common. They should not be simple types. but actual objects. +//! Simple types belong in `common/types`. -pub(crate) mod cip36; +pub(crate) mod cip36; // TODO: Not common, to be removed once code refactored. pub(crate) mod hash; pub(crate) mod network; pub(crate) mod registration_info; diff --git a/catalyst-gateway/bin/src/service/common/objects/cardano/registration_info.rs b/catalyst-gateway/bin/src/service/common/objects/cardano/registration_info.rs index cc23ffb0f68..24eeb2cb09b 100644 --- a/catalyst-gateway/bin/src/service/common/objects/cardano/registration_info.rs +++ b/catalyst-gateway/bin/src/service/common/objects/cardano/registration_info.rs @@ -5,7 +5,7 @@ use poem_openapi::{types::Example, Object, Union}; use crate::service::{ api::cardano::types::{Nonce, PaymentAddress, PublicVotingInfo, TxId}, common::objects::cardano::hash::Hash, - utilities::to_hex_with_prefix, + utilities::as_hex_string, }; /// The Voting power and voting key of a Delegated voter. @@ -76,7 +76,7 @@ impl RegistrationInfo { let voting_info = match voting_info { PublicVotingInfo::Direct(voting_key) => { VotingInfo::Direct(DirectVoter { - voting_key: to_hex_with_prefix(voting_key.bytes()), + voting_key: as_hex_string(voting_key.bytes()), }) }, PublicVotingInfo::Delegated(delegations) => { @@ -85,7 +85,7 @@ impl RegistrationInfo { .into_iter() .map(|(voting_key, power)| { Delegation { - voting_key: to_hex_with_prefix(voting_key.bytes()), + voting_key: as_hex_string(voting_key.bytes()), power, } }) @@ -95,7 +95,7 @@ impl RegistrationInfo { }; Self { tx_hash: tx_hash.into(), - rewards_address: to_hex_with_prefix(rewards_address), + rewards_address: as_hex_string(rewards_address), nonce, voting_info, } diff --git a/catalyst-gateway/bin/src/service/common/objects/cardano/sync_state.rs b/catalyst-gateway/bin/src/service/common/objects/cardano/sync_state.rs index 326d1633406..770dd19e68f 100644 --- a/catalyst-gateway/bin/src/service/common/objects/cardano/sync_state.rs +++ b/catalyst-gateway/bin/src/service/common/objects/cardano/sync_state.rs @@ -8,7 +8,7 @@ use crate::service::{ }; /// Cardano follower's sync state info. -#[derive(Debug, Object)] +#[derive(Object)] #[oai(example = true)] pub(crate) struct SyncState { /// Slot number. diff --git a/catalyst-gateway/bin/src/service/common/objects/generic/mod.rs b/catalyst-gateway/bin/src/service/common/objects/generic/mod.rs new file mode 100644 index 00000000000..9f5eec6f164 --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/objects/generic/mod.rs @@ -0,0 +1,3 @@ +//! Generic Objects + +pub(crate) mod pagination; diff --git a/catalyst-gateway/bin/src/service/common/objects/generic/pagination.rs b/catalyst-gateway/bin/src/service/common/objects/generic/pagination.rs new file mode 100644 index 00000000000..278fbc65d5d --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/objects/generic/pagination.rs @@ -0,0 +1,48 @@ +//! Pagination response object to be included in every paged response. + +use poem_openapi::{types::Example, Object}; + +use crate::service::common; + +/// Description for the `CurrentPage` object. +#[allow(dead_code)] +pub(crate) const CURRENT_PAGE_DESCRIPTION: &str = + "The Page of results is being returned, and the Limit of results. +The data returned is constrained by this limit. +The limit applies to the total number of records returned. +*Note: The Limit may not be exactly as requested, if it was constrained by the response. +The caller must read this record to ensure the correct data requested was returned.*"; + +#[derive(Object)] +#[oai(example = true)] +/// Current Page of data being returned. +pub(crate) struct CurrentPage { + #[allow(clippy::missing_docs_in_private_items)] // Type is pre documented + pub page: common::types::generic::query::pagination::Page, + #[allow(clippy::missing_docs_in_private_items)] // Type is pre documented + pub limit: common::types::generic::query::pagination::Limit, + #[allow(clippy::missing_docs_in_private_items)] // Type is pre documented + pub remaining: common::types::generic::query::pagination::Remaining, +} + +impl Example for CurrentPage { + fn example() -> Self { + Self { + page: common::types::generic::query::pagination::Page::example(), + limit: common::types::generic::query::pagination::Limit::example(), + remaining: common::types::generic::query::pagination::Remaining::example(), + } + } +} + +impl CurrentPage { + /// Create a new `CurrentPage` object. + #[allow(dead_code)] + fn new(page: u64, limit: u64, remaining: u64) -> Self { + Self { + page: page.into(), + limit: limit.into(), + remaining: remaining.into(), + } + } +} diff --git a/catalyst-gateway/bin/src/service/common/objects/mod.rs b/catalyst-gateway/bin/src/service/common/objects/mod.rs index 68c270166cf..c5e863052f3 100644 --- a/catalyst-gateway/bin/src/service/common/objects/mod.rs +++ b/catalyst-gateway/bin/src/service/common/objects/mod.rs @@ -2,4 +2,5 @@ pub(crate) mod cardano; pub(crate) mod config; +pub(crate) mod generic; pub(crate) mod legacy; diff --git a/catalyst-gateway/bin/src/service/common/responses/code_401_unauthorized.rs b/catalyst-gateway/bin/src/service/common/responses/code_401_unauthorized.rs index 962db861df8..445895f2bdd 100644 --- a/catalyst-gateway/bin/src/service/common/responses/code_401_unauthorized.rs +++ b/catalyst-gateway/bin/src/service/common/responses/code_401_unauthorized.rs @@ -3,32 +3,39 @@ use poem_openapi::{types::Example, Object}; use uuid::Uuid; -#[derive(Debug, Object)] -#[oai(example, skip_serializing_if_is_none)] -/// Server Error response to a Bad request. +use crate::service::common; + +#[derive(Object)] +#[oai(example)] +// Keep this message consistent with the response comment. +/// The client has not sent valid authentication credentials for the requested +/// resource. pub(crate) struct Unauthorized { /// Unique ID of this Server Error so that it can be located easily for debugging. id: Uuid, /// Error message. // Will not contain sensitive information, internal details or backtraces. - #[oai(validator(max_length = "1000", pattern = "^[0-9a-zA-Z].*$"))] - msg: String, + //#[oai(validator(max_length = "1000", pattern = "^[0-9a-zA-Z].*$"))] + msg: common::types::generic::error_msg::ErrorMessage, } impl Unauthorized { - /// Create a new Server Error Response Payload. + /// Create a new Payload. pub(crate) fn new(msg: Option) -> Self { let msg = msg.unwrap_or( "Your request was not successful because it lacks valid authentication credentials for the requested resource.".to_string(), ); let id = Uuid::new_v4(); - Self { id, msg } + Self { + id, + msg: msg.into(), + } } } impl Example for Unauthorized { - /// Example for the Too Many Requests Payload. + /// Example fn example() -> Self { Self::new(None) } diff --git a/catalyst-gateway/bin/src/service/common/responses/code_403_forbidden.rs b/catalyst-gateway/bin/src/service/common/responses/code_403_forbidden.rs index b087e2350a7..8ef89206dc9 100644 --- a/catalyst-gateway/bin/src/service/common/responses/code_403_forbidden.rs +++ b/catalyst-gateway/bin/src/service/common/responses/code_403_forbidden.rs @@ -3,9 +3,10 @@ use poem_openapi::{types::Example, Object}; use uuid::Uuid; -#[derive(Debug, Object)] -#[oai(example, skip_serializing_if_is_none)] -/// Server Error response to a Bad request. +#[derive(Object)] +#[oai(example)] +/// The client has not sent valid authentication credentials for the requested +/// resource. pub(crate) struct Forbidden { /// Unique ID of this Server Error so that it can be located easily for debugging. id: Uuid, diff --git a/catalyst-gateway/bin/src/service/common/responses/code_422_unprocessable_content.rs b/catalyst-gateway/bin/src/service/common/responses/code_422_unprocessable_content.rs index b37eb9cc9b6..e2b8061248e 100644 --- a/catalyst-gateway/bin/src/service/common/responses/code_422_unprocessable_content.rs +++ b/catalyst-gateway/bin/src/service/common/responses/code_422_unprocessable_content.rs @@ -2,32 +2,69 @@ use poem_openapi::{types::Example, Object}; -#[derive(Debug, Object)] -#[oai(example, skip_serializing_if_is_none)] +use crate::service::common; + +#[derive(Object)] +#[oai(example)] +/// The client has not sent valid data in its request, headers, parameters or body. +pub(crate) struct UnprocessableContent { + #[oai(validator(max_items = "1000", min_items = "1"))] + /// Details of each error in the content that was detected. + /// + /// Note: This may not be ALL errors in the content, as validation of content can stop + /// at any point an error is detected. + detail: Vec, +} + +impl UnprocessableContent { + /// Create a new `ContentErrorDetail` Response Payload. + pub(crate) fn new(errors: Vec) -> Self { + let mut detail = vec![]; + for error in errors { + detail.push(ContentErrorDetail::new(&error)); + } + + Self { detail } + } +} + +impl Example for UnprocessableContent { + /// Example for the Too Many Requests Payload. + fn example() -> Self { + Self { + detail: vec![ContentErrorDetail::example()], + } + } +} + +//-------------------------------------------------------------------------------------- + +#[derive(Object)] +#[oai(example)] /// Individual details of a single error that was detected with the content of the /// request. pub(crate) struct ContentErrorDetail { /// The location of the error - #[oai(validator(max_items = 100, max_length = "1000", pattern = "^[0-9a-zA-Z].*$"))] - loc: Option>, + #[oai(validator(max_items = 100))] + loc: Option>, /// The error message. #[oai(validator(max_length = "1000", pattern = "^[0-9a-zA-Z].*$"))] - msg: Option, + msg: Option, /// The type of error #[oai( rename = "type", validator(max_length = "1000", pattern = "^[0-9a-zA-Z].*$") )] - err_type: Option, + err_type: Option, } impl Example for ContentErrorDetail { /// Example for the `ContentErrorDetail` Payload. fn example() -> Self { Self { - loc: Some(vec!["body".to_owned()]), - msg: Some("Value is not a valid dict.".to_owned()), - err_type: Some("type_error.dict".to_owned()), + loc: Some(vec!["body".into()]), + msg: Some("Value is not a valid dict.".into()), + err_type: Some("type_error.dict".into()), } } } @@ -38,41 +75,8 @@ impl ContentErrorDetail { // TODO: See if we can get more info from the error than this. Self { loc: None, - msg: Some(error.to_string()), + msg: Some(error.to_string().into()), err_type: None, } } } - -#[derive(Debug, Object)] -#[oai(example, skip_serializing_if_is_none)] -/// Server Error response to a Bad request. -pub(crate) struct UnprocessableContent { - #[oai(validator(max_items = "1000", min_items = "1"))] - /// Details of each error in the content that was detected. - /// - /// Note: This may not be ALL errors in the content, as validation of content can stop - /// at any point an error is detected. - detail: Vec, -} - -impl UnprocessableContent { - /// Create a new `ContentErrorDetail` Response Payload. - pub(crate) fn new(errors: Vec) -> Self { - let mut detail = vec![]; - for error in errors { - detail.push(ContentErrorDetail::new(&error)); - } - - Self { detail } - } -} - -impl Example for UnprocessableContent { - /// Example for the Too Many Requests Payload. - fn example() -> Self { - Self { - detail: vec![ContentErrorDetail::example()], - } - } -} diff --git a/catalyst-gateway/bin/src/service/common/responses/code_429_too_many_requests.rs b/catalyst-gateway/bin/src/service/common/responses/code_429_too_many_requests.rs index 195f40a09d2..d3f07429e52 100644 --- a/catalyst-gateway/bin/src/service/common/responses/code_429_too_many_requests.rs +++ b/catalyst-gateway/bin/src/service/common/responses/code_429_too_many_requests.rs @@ -3,9 +3,9 @@ use poem_openapi::{types::Example, Object}; use uuid::Uuid; -#[derive(Debug, Object)] -#[oai(example, skip_serializing_if_is_none)] -/// Server Error response to a Bad request. +#[derive(Object)] +#[oai(example)] +/// The client has sent too many requests in a given amount of time. pub(crate) struct TooManyRequests { /// Unique ID of this Server Error so that it can be located easily for debugging. id: Uuid, diff --git a/catalyst-gateway/bin/src/service/common/responses/code_500_internal_server_error.rs b/catalyst-gateway/bin/src/service/common/responses/code_500_internal_server_error.rs index d3ad5189c47..f7fc977cd73 100644 --- a/catalyst-gateway/bin/src/service/common/responses/code_500_internal_server_error.rs +++ b/catalyst-gateway/bin/src/service/common/responses/code_500_internal_server_error.rs @@ -8,9 +8,11 @@ use uuid::Uuid; /// probably want to place this in your crate root use crate::settings::Settings; -#[derive(Debug, Object)] +#[derive(Object)] #[oai(example, skip_serializing_if_is_none)] -/// Server Error response to a Bad request. +/// An internal server error occurred. +/// +/// *The contents of this response should be reported to the projects issue tracker.* pub(crate) struct InternalServerError { /// Unique ID of this Server Error so that it can be located easily for debugging. id: Uuid, diff --git a/catalyst-gateway/bin/src/service/common/responses/code_503_service_unavailable.rs b/catalyst-gateway/bin/src/service/common/responses/code_503_service_unavailable.rs index 48a840692e0..b5c67daf8b3 100644 --- a/catalyst-gateway/bin/src/service/common/responses/code_503_service_unavailable.rs +++ b/catalyst-gateway/bin/src/service/common/responses/code_503_service_unavailable.rs @@ -3,9 +3,12 @@ use poem_openapi::{types::Example, Object}; use uuid::Uuid; -#[derive(Debug, Object)] -#[oai(example, skip_serializing_if_is_none)] -/// Server Error response to a Bad request. +#[derive(Object)] +#[oai(example)] +/// The service is not available, try again later. +/// +/// *This is returned when the service either has not started, +/// or has become unavailable.* pub(crate) struct ServiceUnavailable { /// Unique ID of this Server Error so that it can be located easily for debugging. id: Uuid, diff --git a/catalyst-gateway/bin/src/service/common/responses/mod.rs b/catalyst-gateway/bin/src/service/common/responses/mod.rs index 31972cb3c3b..7f86a27c52c 100644 --- a/catalyst-gateway/bin/src/service/common/responses/mod.rs +++ b/catalyst-gateway/bin/src/service/common/responses/mod.rs @@ -75,7 +75,7 @@ pub(crate) enum ErrorResponses { /// ## Service Unavailable /// - /// The service is not available, do not send other requests. + /// The service is not available, try again later. /// /// *This is returned when the service either has not started, /// or has become unavailable.* diff --git a/catalyst-gateway/bin/src/service/common/types/cardano/asset_value.rs b/catalyst-gateway/bin/src/service/common/types/cardano/asset_value.rs index fc858682df1..2fcf64a82fb 100644 --- a/catalyst-gateway/bin/src/service/common/types/cardano/asset_value.rs +++ b/catalyst-gateway/bin/src/service/common/types/cardano/asset_value.rs @@ -17,9 +17,11 @@ const DESCRIPTION: &str = "This is a non-zero signed integer."; const EXAMPLE: i128 = 1_234_567; /// Minimum. /// From: +/// This is NOT `i128::MIN`. const MINIMUM: i128 = -9_223_372_036_854_775_808; /// Maximum. /// From: +/// This is NOT `i128::MAX`. const MAXIMUM: i128 = 9_223_372_036_854_775_808; /// Schema. diff --git a/catalyst-gateway/bin/src/service/common/types/cardano/cip19_shelley_address.rs b/catalyst-gateway/bin/src/service/common/types/cardano/cip19_shelley_address.rs new file mode 100644 index 00000000000..a6eac96080a --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/types/cardano/cip19_shelley_address.rs @@ -0,0 +1,144 @@ +//! Cardano address types. +//! +//! More information can be found in [CIP-19](https://cips.cardano.org/cip/CIP-19) + +use std::{ + borrow::Cow, + ops::{Deref, DerefMut}, + sync::LazyLock, +}; + +use const_format::concatcp; +use pallas::ledger::addresses::{Address, ShelleyAddress}; +use poem_openapi::{ + registry::{MetaExternalDocument, MetaSchema, MetaSchemaRef}, + types::{Example, ParseError, ParseFromJSON, ParseFromParameter, ParseResult, ToJSON, Type}, +}; +use serde_json::Value; + +use crate::service::common::types::string_types::impl_string_types; + +/// Title +const TITLE: &str = "Cardano Payment Address"; +/// Description +const DESCRIPTION: &str = "Cardano Shelley Payment Address (CIP-19 Formatted)."; +/// Example +// cSpell:disable +const EXAMPLE: &str = "addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3n0d3vllmyqwsx5wktcd8cc3sq835lu7drv2xwl2wywfgs68faae"; +// cSpell:enable +/// Production Address Identifier +const PROD: &str = "addr"; +/// Test Address Identifier +const TEST: &str = "addr_test"; +/// Bech32 Match Pattern +const BECH32: &str = "[a,c-h,j-n,p-z,0,2-9]"; +/// Length of the encoded address (for type 0 - 3). +const ENCODED_STAKED_ADDR_LEN: usize = 98; +/// Length of the encoded address (for type 6 - 7). +const ENCODED_UNSTAKED_ADDR_LEN: usize = 53; +/// Regex Pattern +const PATTERN: &str = concatcp!( + "(", + PROD, + "|", + TEST, + ")1(", + BECH32, + "{", + ENCODED_UNSTAKED_ADDR_LEN, + "}|", + BECH32, + "{", + ENCODED_STAKED_ADDR_LEN, + "})" +); +/// Length of the decoded address. +const DECODED_UNSTAKED_ADDR_LEN: usize = 28; +/// Length of the decoded address. +const DECODED_STAKED_ADDR_LEN: usize = DECODED_UNSTAKED_ADDR_LEN * 2; +/// Minimum length +const MIN_LENGTH: usize = PROD.len() + 1 + ENCODED_UNSTAKED_ADDR_LEN; +/// Minimum length +const MAX_LENGTH: usize = TEST.len() + 1 + ENCODED_STAKED_ADDR_LEN; + +/// External document for Cardano addresses. +static EXTERNAL_DOCS: LazyLock = LazyLock::new(|| { + MetaExternalDocument { + url: "https://cips.cardano.org/cip/CIP-19".to_owned(), + description: Some("CIP-19 - Cardano Addresses".to_owned()), + } +}); + +/// Schema. +static SCHEMA: LazyLock = LazyLock::new(|| { + MetaSchema { + title: Some(TITLE.to_owned()), + description: Some(DESCRIPTION), + example: Some(Value::String(EXAMPLE.to_string())), + external_docs: Some(EXTERNAL_DOCS.clone()), + min_length: Some(MIN_LENGTH), + max_length: Some(MAX_LENGTH), + pattern: Some(PATTERN.to_string()), + ..poem_openapi::registry::MetaSchema::ANY + } +}); + +/// Because ALL the constraints are defined above, we do not ever need to define them in +/// the API. BUT we do need to make a validator. +/// This helps enforce uniform validation. +fn is_valid(addr: &str) -> bool { + // Just check the string can be safely converted into the type. + if let Ok((hrp, addr)) = bech32::decode(addr) { + let hrp = hrp.as_str(); + (addr.len() == DECODED_UNSTAKED_ADDR_LEN || addr.len() == DECODED_STAKED_ADDR_LEN) + && (hrp == PROD || hrp == TEST) + } else { + false + } +} + +impl_string_types!( + Cip19ShelleyAddress, + "string", + "cardano:cip19-address", + Some(SCHEMA.clone()), + is_valid +); + +impl Cip19ShelleyAddress { + /// Create a new `PaymentAddress`. + #[allow(dead_code)] + pub fn new(address: String) -> Self { + Cip19ShelleyAddress(address) + } +} + +impl TryFrom for Cip19ShelleyAddress { + type Error = anyhow::Error; + + fn try_from(addr: ShelleyAddress) -> Result { + let addr_str = addr + .to_bech32() + .map_err(|e| anyhow::anyhow!(format!("Invalid payment address {e}")))?; + Ok(Self(addr_str)) + } +} + +impl TryInto for Cip19ShelleyAddress { + type Error = anyhow::Error; + + fn try_into(self) -> Result { + let address_str = &self.0; + let address = Address::from_bech32(address_str)?; + match address { + Address::Shelley(address) => Ok(address), + _ => Err(anyhow::anyhow!("Invalid payment address")), + } + } +} + +impl Example for Cip19ShelleyAddress { + fn example() -> Self { + Self(EXAMPLE.to_owned()) + } +} diff --git a/catalyst-gateway/bin/src/service/common/types/cardano/address.rs b/catalyst-gateway/bin/src/service/common/types/cardano/cip19_stake_address.rs similarity index 66% rename from catalyst-gateway/bin/src/service/common/types/cardano/address.rs rename to catalyst-gateway/bin/src/service/common/types/cardano/cip19_stake_address.rs index 9a3a2e70cc8..3ae12fbc3b1 100644 --- a/catalyst-gateway/bin/src/service/common/types/cardano/address.rs +++ b/catalyst-gateway/bin/src/service/common/types/cardano/cip19_stake_address.rs @@ -8,6 +8,7 @@ use std::{ sync::LazyLock, }; +use anyhow::bail; use const_format::concatcp; use pallas::ledger::addresses::{Address, StakeAddress}; use poem_openapi::{ @@ -24,14 +25,14 @@ const TITLE: &str = "Cardano stake address"; const DESCRIPTION: &str = "Cardano stake address, also known as a reward address."; /// Stake address example. // cSpell:disable -const EXAMPLE: &str = "stake_test1uqehkck0lajq8gr28t9uxnuvgcqrc6070x3k9r8048z8y5gssrtvn"; +pub(crate) const EXAMPLE: &str = "stake_test1uqehkck0lajq8gr28t9uxnuvgcqrc6070x3k9r8048z8y5gssrtvn"; // cSpell:enable /// Production Stake Address Identifier const PROD_STAKE: &str = "stake"; /// Test Stake Address Identifier const TEST_STAKE: &str = "stake_test"; /// Regex Pattern -const PATTERN: &str = concatcp!( +pub(crate) const PATTERN: &str = concatcp!( "(", PROD_STAKE, "|", @@ -43,9 +44,12 @@ const ENCODED_ADDR_LEN: usize = 53; /// Length of the decoded address. const DECODED_ADDR_LEN: usize = 28; /// Minimum length -const MIN_LENGTH: usize = PROD_STAKE.len() + 1 + ENCODED_ADDR_LEN; +pub(crate) const MIN_LENGTH: usize = PROD_STAKE.len() + 1 + ENCODED_ADDR_LEN; /// Minimum length -const MAX_LENGTH: usize = TEST_STAKE.len() + 1 + ENCODED_ADDR_LEN; +pub(crate) const MAX_LENGTH: usize = TEST_STAKE.len() + 1 + ENCODED_ADDR_LEN; + +/// String Format +pub(crate) const FORMAT: &str = "cardano:cip19-address"; /// External document for Cardano addresses. static EXTERNAL_DOCS: LazyLock = LazyLock::new(|| { @@ -85,31 +89,42 @@ fn is_valid(stake_addr: &str) -> bool { impl_string_types!( Cip19StakeAddress, "string", - "cardano:cip19-address", + FORMAT, Some(STAKE_SCHEMA.clone()), is_valid ); -impl Cip19StakeAddress { - /// Create a new `StakeAddress`. - #[allow(dead_code)] - pub fn new(address: String) -> Self { - Cip19StakeAddress(address) +impl TryFrom<&str> for Cip19StakeAddress { + type Error = anyhow::Error; + + fn try_from(value: &str) -> Result { + value.to_string().try_into() } +} - /// Convert a `StakeAddress` string to a `StakeAddress`. - pub fn to_stake_address(&self) -> anyhow::Result { - let address_str = &self.0; - let address = Address::from_bech32(address_str)?; - match address { - Address::Stake(stake_address) => Ok(stake_address), - _ => Err(anyhow::anyhow!("Invalid stake address")), - } +impl TryFrom for Cip19StakeAddress { + type Error = anyhow::Error; + + fn try_from(value: String) -> Result { + match bech32::decode(&value) { + Ok((hrp, addr)) => { + let hrp = hrp.as_str(); + if addr.len() == DECODED_ADDR_LEN && (hrp == PROD_STAKE || hrp == TEST_STAKE) { + return Ok(Cip19StakeAddress(value)); + } + bail!("Invalid CIP-19 formatted Stake Address") + }, + Err(err) => { + bail!("Invalid CIP-19 formatted Stake Address : {err}"); + }, + }; } +} - /// Convert a `StakeAddress` to a `StakeAddress` string. - #[allow(dead_code)] - pub fn from_stake_address(addr: &StakeAddress) -> anyhow::Result { +impl TryFrom for Cip19StakeAddress { + type Error = anyhow::Error; + + fn try_from(addr: StakeAddress) -> Result { let addr_str = addr .to_bech32() .map_err(|e| anyhow::anyhow!(format!("Invalid stake address {e}")))?; @@ -117,6 +132,19 @@ impl Cip19StakeAddress { } } +impl TryInto for Cip19StakeAddress { + type Error = anyhow::Error; + + fn try_into(self) -> Result { + let address_str = &self.0; + let address = Address::from_bech32(address_str)?; + match address { + Address::Stake(address) => Ok(address), + _ => Err(anyhow::anyhow!("Invalid stake address")), + } + } +} + impl Example for Cip19StakeAddress { fn example() -> Self { Self(EXAMPLE.to_owned()) diff --git a/catalyst-gateway/bin/src/service/common/types/cardano/hash28.rs b/catalyst-gateway/bin/src/service/common/types/cardano/hash28.rs index 4b3e97cee9a..5c44725d835 100644 --- a/catalyst-gateway/bin/src/service/common/types/cardano/hash28.rs +++ b/catalyst-gateway/bin/src/service/common/types/cardano/hash28.rs @@ -15,7 +15,10 @@ use poem_openapi::{ }; use serde_json::Value; -use crate::service::common::types::string_types::impl_string_types; +use crate::service::{ + common::types::string_types::impl_string_types, + utilities::{as_hex_string, from_hex_string}, +}; /// Title. const TITLE: &str = "28 Byte Hash"; @@ -78,7 +81,7 @@ impl TryFrom> for HexEncodedHash28 { if value.len() != HASH_LENGTH { bail!("Hash Length Invalid.") } - Ok(Self(format!("0x{}", hex::encode(value)))) + Ok(Self(as_hex_string(&value))) } } @@ -87,9 +90,7 @@ impl TryFrom> for HexEncodedHash28 { // All creation of this type should come only from one of the deserialization methods. impl From for Vec { fn from(val: HexEncodedHash28) -> Self { - #[allow(clippy::string_slice)] // 100% safe due to the way this type can be constructed. - let raw_hex = &val.0[2..]; #[allow(clippy::expect_used)] - hex::decode(raw_hex).expect("This can only fail if the type was invalidly constructed.") + from_hex_string(&val.0).expect("This can only fail if the type was invalidly constructed.") } } diff --git a/catalyst-gateway/bin/src/service/common/types/cardano/mod.rs b/catalyst-gateway/bin/src/service/common/types/cardano/mod.rs index d4c4deac6b9..f50e83f268d 100644 --- a/catalyst-gateway/bin/src/service/common/types/cardano/mod.rs +++ b/catalyst-gateway/bin/src/service/common/types/cardano/mod.rs @@ -1,6 +1,11 @@ //! Cardano Types -pub(crate) mod address; pub(crate) mod asset_name; pub(crate) mod asset_value; +pub(crate) mod cip19_shelley_address; +pub(crate) mod cip19_stake_address; pub(crate) mod hash28; +pub(crate) mod nonce; +pub(crate) mod query; +pub(crate) mod slot_no; +pub(crate) mod txn_index; diff --git a/catalyst-gateway/bin/src/service/common/types/cardano/nonce.rs b/catalyst-gateway/bin/src/service/common/types/cardano/nonce.rs new file mode 100644 index 00000000000..e99679b7d7f --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/types/cardano/nonce.rs @@ -0,0 +1,126 @@ +//! Nonce + +use std::sync::LazyLock; + +use poem_openapi::{ + registry::{MetaSchema, MetaSchemaRef}, + types::{Example, ParseError, ParseFromJSON, ParseFromParameter, ParseResult, ToJSON, Type}, +}; +use serde_json::Value; + +use super::slot_no; + +/// Title. +const TITLE: &str = "Nonce"; +/// Description. +const DESCRIPTION: &str = "The current slot at the time a transaction was posted. +Used to ensure out of order inclusion on-chain can be detected. + +*Note: Because a Nonce should never be greater than the slot of the transaction it is found in, +excessively large nonces are capped to the transactions slot number.*"; +/// Example. +pub(crate) const EXAMPLE: u64 = slot_no::EXAMPLE; +/// Minimum. +const MINIMUM: u64 = 0; +/// Maximum. +const MAXIMUM: u64 = u64::MAX; + +/// Schema. +#[allow(clippy::cast_precision_loss)] +static SCHEMA: LazyLock = LazyLock::new(|| { + MetaSchema { + title: Some(TITLE.to_owned()), + description: Some(DESCRIPTION), + example: Some(EXAMPLE.into()), + maximum: Some(MAXIMUM as f64), + minimum: Some(MINIMUM as f64), + ..poem_openapi::registry::MetaSchema::ANY + } +}); + +/// Value of a Nonce. +#[derive(Debug, Eq, PartialEq, Hash)] +pub(crate) struct Nonce(u64); + +/// Is the Nonce valid? +fn is_valid(value: u64) -> bool { + (MINIMUM..=MAXIMUM).contains(&value) +} + +impl Type for Nonce { + type RawElementValueType = Self; + type RawValueType = Self; + + const IS_REQUIRED: bool = true; + + fn name() -> std::borrow::Cow<'static, str> { + "integer(u64)".into() + } + + fn schema_ref() -> MetaSchemaRef { + let schema_ref = + MetaSchemaRef::Inline(Box::new(MetaSchema::new_with_format("integer", "u64"))); + schema_ref.merge(SCHEMA.clone()) + } + + fn as_raw_value(&self) -> Option<&Self::RawValueType> { + Some(self) + } + + fn raw_element_iter<'a>( + &'a self, + ) -> Box + 'a> { + Box::new(self.as_raw_value().into_iter()) + } +} + +impl ParseFromParameter for Nonce { + fn parse_from_parameter(value: &str) -> ParseResult { + let nonce: u64 = value.parse()?; + Ok(Self(nonce)) + } +} + +impl ParseFromJSON for Nonce { + fn parse_from_json(value: Option) -> ParseResult { + let value = value.unwrap_or_default(); + match value { + Value::Number(num) => { + let nonce = num + .as_u64() + .ok_or(ParseError::from("nonce must be a positive integer"))?; + if !is_valid(nonce) { + return Err(ParseError::from("nonce out of valid range")); + } + Ok(Self(nonce)) + }, + _ => Err(ParseError::expected_type(value)), + } + } +} + +impl ToJSON for Nonce { + fn to_json(&self) -> Option { + Some(self.0.into()) + } +} + +impl From for Nonce { + fn from(value: u64) -> Self { + Self(value) + } +} + +impl Nonce { + /// Generic conversion of `Option` to `Option`. + #[allow(dead_code)] + pub(crate) fn into_option>(value: Option) -> Option { + value.map(std::convert::Into::into) + } +} + +impl Example for Nonce { + fn example() -> Self { + Self(EXAMPLE) + } +} diff --git a/catalyst-gateway/bin/src/service/common/types/cardano/query/as_at.rs b/catalyst-gateway/bin/src/service/common/types/cardano/query/as_at.rs new file mode 100644 index 00000000000..f95fbb607f6 --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/types/cardano/query/as_at.rs @@ -0,0 +1,169 @@ +//! Query Parameter that can take either a Blockchain slot Number of Unix Epoch timestamp. +//! +//! Allows better specifying of times that restrict a GET endpoints response. + +//! Hex encoded 28 byte hash. +//! +//! Hex encoded string which represents a 28 byte hash. + +use std::{ + cmp::{max, min}, + fmt::{self, Display}, + sync::LazyLock, +}; + +use anyhow::{bail, Result}; +use chrono::DateTime; +use const_format::concatcp; +use poem_openapi::{ + registry::{MetaSchema, MetaSchemaRef}, + types::{ParseError, ParseFromParameter, ParseResult, Type}, +}; +use regex::Regex; +use serde_json::Value; + +use crate::{service::common::types::cardano::slot_no::SlotNo, settings::Settings}; + +/// Title. +const TITLE: &str = "As At this Time OR Slot."; +/// Description. +const DESCRIPTION: &str = "Restrict the query to this time. +Time can be represented as either the blockchains slot number, +or the number of seconds since midnight 1970, UTC. + +If this parameter is not defined, the query will retrieve data up to the current time."; +/// Example whence. +const EXAMPLE_WHENCE: &str = TIME_DISCRIMINATOR; +/// Example time. +const EXAMPLE_TIME: u64 = 1_730_861_339; // Date and time (UTC): November 6, 2024 2:48:59 AM +/// Example +static EXAMPLE: LazyLock = LazyLock::new(|| { + // Note, the SlotNumber here is wrong, but its not used for generating the example, so + // thats OK. + let example = AsAt((EXAMPLE_WHENCE.to_owned(), EXAMPLE_TIME, 0.into())); + format!("{example}") +}); +/// Time Discriminator +const TIME_DISCRIMINATOR: &str = "TIME"; +/// Slot Discriminator +const SLOT_DISCRIMINATOR: &str = "SLOT"; +/// Validation Regex Pattern +const PATTERN: &str = concatcp!( + "^(", + SLOT_DISCRIMINATOR, + "|", + TIME_DISCRIMINATOR, + r"):(\d{1,20})$" +); +/// Minimum parameter length +static MIN_LENGTH: LazyLock = + LazyLock::new(|| min(TIME_DISCRIMINATOR.len(), SLOT_DISCRIMINATOR.len()) + ":0".len()); +/// Maximum parameter length +static MAX_LENGTH: LazyLock = LazyLock::new(|| { + max(TIME_DISCRIMINATOR.len(), SLOT_DISCRIMINATOR.len()) + ":".len() + u64::MAX.to_string().len() +}); + +/// Parse the `AsAt` parameter from the Query string provided. +fn parse_parameter(param: &str) -> Result<(String, u64)> { + /// Regex to parse the parameter + #[allow(clippy::unwrap_used)] // Safe because the Regex is constant. Can never panic in prod. + static RE: LazyLock = LazyLock::new(|| Regex::new(PATTERN).unwrap()); + + let Some(results) = RE.captures(param) else { + bail!("Not a valid `as_at` parameter."); + }; + let whence = &results[1]; + let Ok(when) = results[2].parse::() else { + bail!( + "Not a valid `as_at` parameter. Invalid {} specified.", + whence + ); + }; + Ok((whence.to_owned(), when)) +} + +/// Schema. +static SCHEMA: LazyLock = LazyLock::new(|| { + MetaSchema { + title: Some(TITLE.to_owned()), + description: Some(DESCRIPTION), + example: Some(Value::String(EXAMPLE.to_string())), + max_length: Some(*MAX_LENGTH), + min_length: Some(*MIN_LENGTH), + pattern: Some(PATTERN.to_string()), + ..poem_openapi::registry::MetaSchema::ANY + } +}); + +/// As at time from query string parameter. +/// Store (Whence, When and decoded `SlotNo`) in a tuple for easier access. +#[derive(Debug, Eq, PartialEq, Hash)] +pub(crate) struct AsAt((String, u64, SlotNo)); + +impl Type for AsAt { + type RawElementValueType = Self; + type RawValueType = Self; + + const IS_REQUIRED: bool = true; + + fn name() -> std::borrow::Cow<'static, str> { + "string(slot or time)".into() + } + + fn schema_ref() -> MetaSchemaRef { + let schema_ref = MetaSchemaRef::Inline(Box::new(MetaSchema::new_with_format( + "string", + "slot or time", + ))); + schema_ref.merge(SCHEMA.clone()) + } + + fn as_raw_value(&self) -> Option<&Self::RawValueType> { + Some(self) + } + + fn raw_element_iter<'a>( + &'a self, + ) -> Box + 'a> { + Box::new(self.as_raw_value().into_iter()) + } +} + +impl ParseFromParameter for AsAt { + fn parse_from_parameter(value: &str) -> ParseResult { + let (whence, when) = parse_parameter(value)?; + let slot = if whence == TIME_DISCRIMINATOR { + let network = Settings::cardano_network(); + let Ok(epoch_time) = when.try_into() else { + return Err(ParseError::from(format!( + "time {when} too far in the future" + ))); + }; + let Some(datetime) = DateTime::from_timestamp(epoch_time, 0) else { + return Err(ParseError::from(format!("invalid time {when}"))); + }; + let Some(slot) = network.time_to_slot(datetime) else { + return Err(ParseError::from(format!( + "invalid time {when} for network: {network}" + ))); + }; + slot + } else { + when + }; + let slot_no: SlotNo = slot.into(); + Ok(Self((whence, when, slot_no))) + } +} + +impl From for SlotNo { + fn from(value: AsAt) -> Self { + value.0 .2 + } +} + +impl Display for AsAt { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}:{}", self.0 .0, self.0 .1) + } +} diff --git a/catalyst-gateway/bin/src/service/common/types/cardano/query/mod.rs b/catalyst-gateway/bin/src/service/common/types/cardano/query/mod.rs new file mode 100644 index 00000000000..2583fded07c --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/types/cardano/query/mod.rs @@ -0,0 +1,9 @@ +//! These types are specifically and only used for Query Parameters +//! +//! They exist due to limitations in the expressiveness of Query parameter constraints in +//! `OpenAPI` + +pub(crate) mod as_at; +pub(crate) mod stake_or_voter; + +pub(crate) use as_at::AsAt; diff --git a/catalyst-gateway/bin/src/service/common/types/cardano/query/stake_or_voter.rs b/catalyst-gateway/bin/src/service/common/types/cardano/query/stake_or_voter.rs new file mode 100644 index 00000000000..63e2aaf7a84 --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/types/cardano/query/stake_or_voter.rs @@ -0,0 +1,200 @@ +//! Query Parameter that can take a CIP-19 stake address, or a hex encoded vote public +//! key. +//! +//! Allows us to have one parameter that can represent two things, uniformly. + +use std::{ + cmp::{max, min}, + sync::LazyLock, +}; + +use anyhow::{bail, Result}; +use const_format::concatcp; +use poem::http::HeaderMap; +use poem_openapi::{ + registry::{MetaSchema, MetaSchemaRef}, + types::{ParseFromParameter, ParseResult, Type}, +}; +use regex::Regex; +use serde_json::Value; + +use crate::service::common::{self, auth::api_key::check_api_key}; + +/// A Query Parameter that can take a CIP-19 stake address, or a public key. +/// Defining these are mutually exclusive, ao a single parameter is required to be used. +#[derive(Clone)] +pub(crate) enum StakeAddressOrPublicKey { + /// A CIP-19 stake address + Address(common::types::cardano::cip19_stake_address::Cip19StakeAddress), + /// A Ed25519 Public Key + PublicKey(common::types::generic::ed25519_public_key::Ed25519HexEncodedPublicKey), + /// Special value that means we try to fetch all possible results. Must be protected + /// with an `APIKey`. + All, +} + +impl From for StakeAddressOrPublicKey { + fn from(value: StakeOrVoter) -> Self { + value.0 .1 + } +} + +impl TryFrom<&str> for StakeAddressOrPublicKey { + type Error = anyhow::Error; + + fn try_from(value: &str) -> std::result::Result { + /// Regex to parse the parameter + #[allow(clippy::unwrap_used)] // Safe because the Regex is constant. Can never panic in prod. + static RE: LazyLock = LazyLock::new(|| Regex::new(PATTERN).unwrap()); + + // First check it is the special "ALL" parameter. + if value == "ALL" { + return Ok(Self::All); + } + + // Otherwise, work out use the regex to work out what it is, and validate it. + if let Some(results) = RE.captures(value) { + if let Some(stake_addr) = results.get(1) { + return Ok(Self::Address(stake_addr.as_str().try_into()?)); + } else if let Some(public_key) = results.get(2) { + return Ok(Self::PublicKey(public_key.as_str().try_into()?)); + } + } + bail!("Not a valid \"Stake or Public Key\" parameter."); + } +} + +/// Title. +const TITLE: &str = "Stake Address or Voting Key."; +/// Description. +const DESCRIPTION: &str = "Restrict the query to this Stake address, or Voters Public Key. +If neither are defined, the stake address(es) from the auth tokens role0 registration are used."; +/// Example +const EXAMPLE: &str = common::types::cardano::cip19_stake_address::EXAMPLE; +/// Stake Address Pattern +const STAKE_PATTERN: &str = common::types::cardano::cip19_stake_address::PATTERN; +/// Voting Key Pattern +const VOTING_KEY_PATTERN: &str = common::types::generic::ed25519_public_key::PATTERN; +/// Validation Regex Pattern +const PATTERN: &str = concatcp!("^(", STAKE_PATTERN, ")|(", VOTING_KEY_PATTERN, ")$"); +/// Minimum parameter length +static MIN_LENGTH: LazyLock = LazyLock::new(|| { + min( + common::types::cardano::cip19_stake_address::MIN_LENGTH, + common::types::generic::ed25519_public_key::ENCODED_LENGTH, + ) +}); +/// Maximum parameter length +static MAX_LENGTH: LazyLock = LazyLock::new(|| { + max( + common::types::cardano::cip19_stake_address::MAX_LENGTH, + common::types::generic::ed25519_public_key::ENCODED_LENGTH, + ) +}); + +/// Format +const FORMAT: &str = concatcp!( + common::types::cardano::cip19_stake_address::FORMAT, + "|", + common::types::generic::ed25519_public_key::FORMAT +); + +/// Schema. +static SCHEMA: LazyLock = LazyLock::new(|| { + MetaSchema { + title: Some(TITLE.to_owned()), + description: Some(DESCRIPTION), + example: Some(Value::String(EXAMPLE.to_string())), + max_length: Some(*MAX_LENGTH), + min_length: Some(*MIN_LENGTH), + pattern: Some(PATTERN.to_string()), + ..poem_openapi::registry::MetaSchema::ANY + } +}); + +/// Either a Stake Address or a ED25519 Public key. +#[derive(Clone)] +pub(crate) struct StakeOrVoter((String, StakeAddressOrPublicKey)); + +impl TryFrom<&str> for StakeOrVoter { + type Error = anyhow::Error; + + fn try_from(value: &str) -> std::result::Result { + Ok(Self((value.to_string(), value.try_into()?))) + } +} + +impl Type for StakeOrVoter { + type RawElementValueType = Self; + type RawValueType = Self; + + const IS_REQUIRED: bool = true; + + fn name() -> std::borrow::Cow<'static, str> { + format!("string({FORMAT})").into() + } + + fn schema_ref() -> MetaSchemaRef { + let schema_ref = + MetaSchemaRef::Inline(Box::new(MetaSchema::new_with_format("string", FORMAT))); + schema_ref.merge(SCHEMA.clone()) + } + + fn as_raw_value(&self) -> Option<&Self::RawValueType> { + Some(self) + } + + fn raw_element_iter<'a>( + &'a self, + ) -> Box + 'a> { + Box::new(self.as_raw_value().into_iter()) + } +} + +impl ParseFromParameter for StakeOrVoter { + fn parse_from_parameter(value: &str) -> ParseResult { + Ok(Self((value.to_string(), value.try_into()?))) + } +} + +impl StakeOrVoter { + /// Is this for ALL results? + pub(crate) fn is_all(&self, headers: &HeaderMap) -> Result { + match self.0 .1 { + StakeAddressOrPublicKey::All => { + check_api_key(headers)?; + Ok(true) + }, + _ => Ok(false), + } + } +} + +impl TryInto for StakeOrVoter { + type Error = anyhow::Error; + + fn try_into( + self, + ) -> Result { + match self.0 .1 { + StakeAddressOrPublicKey::Address(addr) => Ok(addr), + _ => bail!("Not a Stake Address"), + } + } +} + +impl TryInto + for StakeOrVoter +{ + type Error = anyhow::Error; + + fn try_into( + self, + ) -> Result + { + match self.0 .1 { + StakeAddressOrPublicKey::PublicKey(key) => Ok(key), + _ => bail!("Not a Stake Address"), + } + } +} diff --git a/catalyst-gateway/bin/src/service/common/types/cardano/slot_no.rs b/catalyst-gateway/bin/src/service/common/types/cardano/slot_no.rs new file mode 100644 index 00000000000..f2e2f2d2daa --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/types/cardano/slot_no.rs @@ -0,0 +1,131 @@ +//! Slot Number on the blockchain. + +use std::sync::LazyLock; + +use anyhow::bail; +use poem_openapi::{ + registry::{MetaSchema, MetaSchemaRef}, + types::{Example, ParseError, ParseFromJSON, ParseFromParameter, ParseResult, ToJSON, Type}, +}; +use serde_json::Value; + +/// Title. +const TITLE: &str = "Cardano Blockchain Slot Number"; +/// Description. +const DESCRIPTION: &str = "The Slot Number of a Cardano Block on the chain."; +/// Example. +pub(crate) const EXAMPLE: u64 = 1_234_567; +/// Minimum. +const MINIMUM: u64 = 0; +/// Maximum. +const MAXIMUM: u64 = u64::MAX; + +/// Schema. +#[allow(clippy::cast_precision_loss)] +static SCHEMA: LazyLock = LazyLock::new(|| { + MetaSchema { + title: Some(TITLE.to_owned()), + description: Some(DESCRIPTION), + example: Some(EXAMPLE.into()), + maximum: Some(MAXIMUM as f64), + minimum: Some(MINIMUM as f64), + ..poem_openapi::registry::MetaSchema::ANY + } +}); + +/// Slot number +#[derive(Debug, Eq, PartialEq, Hash)] +pub(crate) struct SlotNo(u64); + +/// Is the Slot Number valid? +fn is_valid(_value: u64) -> bool { + true +} + +impl Type for SlotNo { + type RawElementValueType = Self; + type RawValueType = Self; + + const IS_REQUIRED: bool = true; + + fn name() -> std::borrow::Cow<'static, str> { + "integer(u64)".into() + } + + fn schema_ref() -> MetaSchemaRef { + let schema_ref = + MetaSchemaRef::Inline(Box::new(MetaSchema::new_with_format("integer", "u64"))); + schema_ref.merge(SCHEMA.clone()) + } + + fn as_raw_value(&self) -> Option<&Self::RawValueType> { + Some(self) + } + + fn raw_element_iter<'a>( + &'a self, + ) -> Box + 'a> { + Box::new(self.as_raw_value().into_iter()) + } +} + +impl ParseFromParameter for SlotNo { + fn parse_from_parameter(value: &str) -> ParseResult { + let slot: u64 = value.parse()?; + Ok(Self(slot)) + } +} + +impl ParseFromJSON for SlotNo { + fn parse_from_json(value: Option) -> ParseResult { + let value = value.unwrap_or_default(); + if let Value::Number(value) = value { + let value = value + .as_u64() + .ok_or(ParseError::from("invalid slot number"))?; + if !is_valid(value) { + return Err("invalid AssetValue".into()); + } + Ok(Self(value)) + } else { + Err(ParseError::expected_type(value)) + } + } +} + +impl ToJSON for SlotNo { + fn to_json(&self) -> Option { + Some(self.0.into()) + } +} + +impl TryFrom for SlotNo { + type Error = anyhow::Error; + + fn try_from(value: i64) -> Result { + let value: u64 = value.try_into()?; + if !is_valid(value) { + bail!("Invalid Slot Number"); + } + Ok(Self(value)) + } +} + +impl From for SlotNo { + fn from(value: u64) -> Self { + Self(value) + } +} + +impl SlotNo { + /// Generic conversion of `Option` to `Option`. + pub(crate) fn into_option>(value: Option) -> Option { + value.map(std::convert::Into::into) + } +} + +impl Example for SlotNo { + fn example() -> Self { + Self(EXAMPLE) + } +} diff --git a/catalyst-gateway/bin/src/service/common/types/cardano/txn_index.rs b/catalyst-gateway/bin/src/service/common/types/cardano/txn_index.rs new file mode 100644 index 00000000000..7a48bd1c344 --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/types/cardano/txn_index.rs @@ -0,0 +1,147 @@ +//! Transaction Index within a block. + +use std::sync::LazyLock; + +use anyhow::bail; +use poem_openapi::{ + registry::{MetaSchema, MetaSchemaRef}, + types::{Example, ParseError, ParseFromJSON, ParseFromParameter, ParseResult, ToJSON, Type}, +}; +use serde_json::Value; + +/// Title. +const TITLE: &str = "Transaction Index"; +/// Description. +const DESCRIPTION: &str = "The Index of a transaction within a block."; +/// Example. +const EXAMPLE: u16 = 7; +/// Minimum. +const MINIMUM: u16 = 0; +/// Maximum. +const MAXIMUM: u16 = u16::MAX; +/// Invalid Error Msg. +const INVALID_MSG: &str = "Invalid Transaction Index."; + +/// Schema. +#[allow(clippy::cast_lossless)] +static SCHEMA: LazyLock = LazyLock::new(|| { + MetaSchema { + title: Some(TITLE.to_owned()), + description: Some(DESCRIPTION), + example: Some(EXAMPLE.into()), + maximum: Some(MAXIMUM as f64), + minimum: Some(MINIMUM as f64), + ..poem_openapi::registry::MetaSchema::ANY + } +}); + +/// Transaction Index within a block. +#[derive(Debug, Eq, PartialEq, Hash)] +pub(crate) struct TxnIndex(u16); + +/// Is the Slot Number valid? +fn is_valid(_value: u16) -> bool { + true +} + +impl Type for TxnIndex { + type RawElementValueType = Self; + type RawValueType = Self; + + const IS_REQUIRED: bool = true; + + fn name() -> std::borrow::Cow<'static, str> { + "integer(u16)".into() + } + + fn schema_ref() -> MetaSchemaRef { + let schema_ref = + MetaSchemaRef::Inline(Box::new(MetaSchema::new_with_format("integer", "u16"))); + schema_ref.merge(SCHEMA.clone()) + } + + fn as_raw_value(&self) -> Option<&Self::RawValueType> { + Some(self) + } + + fn raw_element_iter<'a>( + &'a self, + ) -> Box + 'a> { + Box::new(self.as_raw_value().into_iter()) + } +} + +impl ParseFromParameter for TxnIndex { + fn parse_from_parameter(value: &str) -> ParseResult { + let idx: u16 = value.parse()?; + Ok(Self(idx)) + } +} + +impl ParseFromJSON for TxnIndex { + fn parse_from_json(value: Option) -> ParseResult { + let value = value.unwrap_or_default(); + if let Value::Number(value) = value { + let value = value + .as_u64() + .ok_or(ParseError::from(INVALID_MSG))? + .try_into()?; + if !is_valid(value) { + return Err(INVALID_MSG.into()); + } + Ok(Self(value)) + } else { + Err(ParseError::expected_type(value)) + } + } +} + +impl ToJSON for TxnIndex { + fn to_json(&self) -> Option { + Some(self.0.into()) + } +} + +impl TryFrom for TxnIndex { + type Error = anyhow::Error; + + fn try_from(value: u64) -> Result { + let value: u16 = value.try_into()?; + if !is_valid(value) { + bail!(INVALID_MSG); + } + Ok(Self(value)) + } +} + +impl TryFrom for TxnIndex { + type Error = anyhow::Error; + + fn try_from(value: i16) -> Result { + let value: u16 = value.try_into()?; + if !is_valid(value) { + bail!(INVALID_MSG); + } + Ok(Self(value)) + } +} + +impl From for TxnIndex { + fn from(value: u16) -> Self { + Self(value) + } +} + +impl TxnIndex { + /// Generic conversion of `Option` to `Option`. + #[allow(dead_code)] + pub(crate) fn into_option>(value: Option) -> Option { + value.map(std::convert::Into::into) + } +} + +impl Example for TxnIndex { + fn example() -> Self { + Self(EXAMPLE) + } +} diff --git a/catalyst-gateway/bin/src/service/common/types/generic/ed25519_public_key.rs b/catalyst-gateway/bin/src/service/common/types/generic/ed25519_public_key.rs index 92fbd2c572d..eb0954f4e61 100644 --- a/catalyst-gateway/bin/src/service/common/types/generic/ed25519_public_key.rs +++ b/catalyst-gateway/bin/src/service/common/types/generic/ed25519_public_key.rs @@ -8,24 +8,30 @@ use std::{ sync::LazyLock, }; +use anyhow::bail; use poem_openapi::{ registry::{MetaSchema, MetaSchemaRef}, types::{Example, ParseError, ParseFromJSON, ParseFromParameter, ParseResult, ToJSON, Type}, }; use serde_json::Value; -use crate::{service::common::types::string_types::impl_string_types, utils::ed25519}; +use crate::{ + service::{common::types::string_types::impl_string_types, utilities::as_hex_string}, + utils::ed25519, +}; /// Title. const TITLE: &str = "Ed25519 Public Key"; /// Description. const DESCRIPTION: &str = "This is a 32 Byte Hex encoded Ed25519 Public Key."; /// Example. -const EXAMPLE: &str = "0x98dbd3d884068eee77e5894c22268d5d12e6484ba713e7ddd595abba308d88d3"; +const EXAMPLE: &str = "0x56CDD154355E078A0990F9E633F9553F7D43A68B2FF9BEF78B9F5C71C808A7C8"; /// Length of the hex encoded string -const ENCODED_LENGTH: usize = ed25519::HEX_ENCODED_LENGTH; +pub(crate) const ENCODED_LENGTH: usize = ed25519::HEX_ENCODED_LENGTH; /// Validation Regex Pattern -const PATTERN: &str = "0x[A-Fa-f0-9]{64}"; +pub(crate) const PATTERN: &str = "0x[A-Fa-f0-9]{64}"; +/// Format +pub(crate) const FORMAT: &str = "hex:ed25519-public-key"; /// Schema static SCHEMA: LazyLock = LazyLock::new(|| { @@ -52,7 +58,7 @@ fn is_valid(hex_key: &str) -> bool { impl_string_types!( Ed25519HexEncodedPublicKey, "string", - "hex:ed25519-public-key", + FORMAT, Some(SCHEMA.clone()), is_valid ); @@ -64,13 +70,55 @@ impl Example for Ed25519HexEncodedPublicKey { } } +impl Ed25519HexEncodedPublicKey { + /// Extra examples of 32 bytes ED25519 Public Key. + pub(crate) fn examples(index: usize) -> Self { + match index { + 0 => { + Self( + "0xDEF855AE45F3BF9640A5298A38B97617DE75600F796F17579BFB815543624B24".to_owned(), + ) + }, + 1 => { + Self( + "0x83B3B55589797EF953E24F4F0DBEE4D50B6363BCF041D15F6DBD33E014E54711".to_owned(), + ) + }, + _ => { + Self( + "0xA3E52361AFDE840918E2589DBAB9967C8027FB4431E83D36E338748CD6E3F820".to_owned(), + ) + }, + } + } +} + +impl TryFrom<&str> for Ed25519HexEncodedPublicKey { + type Error = anyhow::Error; + + fn try_from(value: &str) -> Result { + value.to_string().try_into() + } +} + +impl TryFrom for Ed25519HexEncodedPublicKey { + type Error = anyhow::Error; + + fn try_from(value: String) -> Result { + if !is_valid(&value) { + bail!("Invalid Ed25519 Public key") + } + Ok(Self(value)) + } +} + impl TryFrom> for Ed25519HexEncodedPublicKey { type Error = anyhow::Error; fn try_from(value: Vec) -> Result { let key = ed25519::verifying_key_from_vec(&value)?; - Ok(Self(format!("0x{}", hex::encode(key)))) + Ok(Self(as_hex_string(key.as_ref()))) } } diff --git a/catalyst-gateway/bin/src/service/common/types/generic/error_msg.rs b/catalyst-gateway/bin/src/service/common/types/generic/error_msg.rs new file mode 100644 index 00000000000..f43e82179f2 --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/types/generic/error_msg.rs @@ -0,0 +1,79 @@ +//! Generic Error Messages + +use std::{ + borrow::Cow, + ops::{Deref, DerefMut}, + sync::LazyLock, +}; + +use const_format::concatcp; +use poem_openapi::{ + registry::{MetaSchema, MetaSchemaRef}, + types::{Example, ParseError, ParseFromJSON, ParseFromParameter, ParseResult, ToJSON, Type}, +}; +use regex::Regex; +use serde_json::Value; + +use crate::service::common::types::string_types::impl_string_types; + +/// Title. +const TITLE: &str = "Error Message"; +/// Description. +const DESCRIPTION: &str = "This is an error message."; +/// Example. +const EXAMPLE: &str = "An error has occurred, the details of the error are ..."; +/// Max Length +const MAX_LENGTH: usize = 256; +/// Min Length +const MIN_LENGTH: usize = 1; +/// Validation Regex Pattern +const PATTERN: &str = concatcp!("^(.){", MIN_LENGTH, ",", MAX_LENGTH, "}$"); + +/// Schema +static SCHEMA: LazyLock = LazyLock::new(|| { + MetaSchema { + title: Some(TITLE.to_owned()), + description: Some(DESCRIPTION), + example: Some(Value::String(EXAMPLE.to_string())), + max_length: Some(MAX_LENGTH), + min_length: Some(MIN_LENGTH), + pattern: Some(PATTERN.to_string()), + ..poem_openapi::registry::MetaSchema::ANY + } +}); + +/// Check if we match the regex. +fn is_valid(msg: &str) -> bool { + /// Validation pattern + #[allow(clippy::unwrap_used)] // Safe because the Regex is constant. Can never panic in prod. + static RE: LazyLock = LazyLock::new(|| Regex::new(PATTERN).unwrap()); + + RE.is_match(msg) +} + +impl_string_types!( + ErrorMessage, + "string", + "error", + Some(SCHEMA.clone()), + is_valid +); + +impl Example for ErrorMessage { + /// An example 32 bytes ED25519 Public Key. + fn example() -> Self { + Self(EXAMPLE.to_owned()) + } +} + +impl From for ErrorMessage { + fn from(val: String) -> Self { + Self(val) + } +} + +impl From<&str> for ErrorMessage { + fn from(val: &str) -> Self { + Self(val.to_owned()) + } +} diff --git a/catalyst-gateway/bin/src/service/common/types/generic/mod.rs b/catalyst-gateway/bin/src/service/common/types/generic/mod.rs index 27d8845f262..a04a6ed0a59 100644 --- a/catalyst-gateway/bin/src/service/common/types/generic/mod.rs +++ b/catalyst-gateway/bin/src/service/common/types/generic/mod.rs @@ -3,3 +3,5 @@ //! These types may be used in Cardano, but are not specific to Cardano. pub(crate) mod ed25519_public_key; +pub(crate) mod error_msg; +pub(crate) mod query; diff --git a/catalyst-gateway/bin/src/service/common/types/generic/query/mod.rs b/catalyst-gateway/bin/src/service/common/types/generic/query/mod.rs new file mode 100644 index 00000000000..bc560e0ba21 --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/types/generic/query/mod.rs @@ -0,0 +1,12 @@ +//! Generic Query ONLY parameters. + +pub(crate) mod pagination; + +// To add pagination to an endpoint add these two lines to the parameters: +// +// ``` +// #[doc = common::types::generic::query::pagination::PAGE_DESCRIPTION] +// page: Query>, +// #[doc = common::types::generic::query::pagination::LIMIT_DESCRIPTION] +// limit: Query> +// ``` diff --git a/catalyst-gateway/bin/src/service/common/types/generic/query/pagination.rs b/catalyst-gateway/bin/src/service/common/types/generic/query/pagination.rs new file mode 100644 index 00000000000..85824bd4a74 --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/types/generic/query/pagination.rs @@ -0,0 +1,360 @@ +//! Consistent Pagination Types +//! +//! These types are paired and must be used together. +//! +//! Page - The Page we wish to request, defaults to 0. +//! Limit - The Limit we wish to request, defaults to 100. + +use std::sync::LazyLock; + +use poem_openapi::{ + registry::{MetaSchema, MetaSchemaRef}, + types::{Example, ParseError, ParseFromJSON, ParseFromParameter, ParseResult, ToJSON, Type}, +}; +use serde_json::Value; + +//***** PAGE */ +/// Page Title. +const PAGE_TITLE: &str = "Page"; +/// Description. +macro_rules! page_description { + () => { + "The page number of the data. +The size of each page, and its offset within the complete data set is determined by the `limit` parameter." + }; +} +pub(crate) use page_description; +/// Description +pub(crate) const PAGE_DESCRIPTION: &str = page_description!(); +/// Example. +const PAGE_EXAMPLE: u64 = 5; +/// Default +const PAGE_DEFAULT: u64 = 0; +/// Page Minimum. +const PAGE_MINIMUM: u64 = 0; +/// Page Maximum. +const PAGE_MAXIMUM: u64 = u64::MAX; + +/// Schema. +#[allow(clippy::cast_precision_loss)] +static PAGE_SCHEMA: LazyLock = LazyLock::new(|| { + MetaSchema { + title: Some(PAGE_TITLE.to_owned()), + description: Some(PAGE_DESCRIPTION), + example: Some(PAGE_EXAMPLE.into()), + default: Page(PAGE_DEFAULT).to_json(), + maximum: Some(PAGE_MAXIMUM as f64), + minimum: Some(PAGE_MINIMUM as f64), + ..poem_openapi::registry::MetaSchema::ANY + } +}); + +/// Page to be returned in the response. +#[derive(Debug, Eq, PartialEq, Hash)] +pub(crate) struct Page(u64); + +impl Default for Page { + fn default() -> Self { + Self(PAGE_DEFAULT) + } +} + +/// Is the `Page` valid? +fn is_valid_page(value: u64) -> bool { + (PAGE_MINIMUM..=PAGE_MAXIMUM).contains(&value) +} + +impl Type for Page { + type RawElementValueType = Self; + type RawValueType = Self; + + const IS_REQUIRED: bool = true; + + fn name() -> std::borrow::Cow<'static, str> { + "integer(u64)".into() + } + + fn schema_ref() -> MetaSchemaRef { + let schema_ref = + MetaSchemaRef::Inline(Box::new(MetaSchema::new_with_format("integer", "u64"))); + schema_ref.merge(PAGE_SCHEMA.clone()) + } + + fn as_raw_value(&self) -> Option<&Self::RawValueType> { + Some(self) + } + + fn raw_element_iter<'a>( + &'a self, + ) -> Box + 'a> { + Box::new(self.as_raw_value().into_iter()) + } +} + +impl ParseFromParameter for Page { + fn parse_from_parameter(value: &str) -> ParseResult { + let page: u64 = value.parse()?; + Ok(Page(page)) + } +} + +impl ParseFromJSON for Page { + fn parse_from_json(value: Option) -> ParseResult { + let value = value.unwrap_or_default(); + if let Value::Number(value) = value { + let value = value.as_u64().unwrap_or_default(); + if !is_valid_page(value) { + return Err("invalid Page".into()); + } + Ok(Self(value)) + } else { + Err(ParseError::expected_type(value)) + } + } +} + +impl ToJSON for Page { + fn to_json(&self) -> Option { + Some(self.0.into()) + } +} + +impl From for Page { + fn from(value: u64) -> Self { + Self(value) + } +} + +impl Example for Page { + fn example() -> Self { + Self(PAGE_EXAMPLE) + } +} + +//***** LIMIT */ +/// Title. +const LIMIT_TITLE: &str = "Limit"; +/// Description - must be suitable for both the Query and Response docs. +macro_rules! limit_description { + () => { + "The size `limit` of each `page` of results. +Determines the maximum amount of data that can be returned in a valid response. + +This `limit` of records of data will always be returned unless there is less data to return +than allowed for by the `limit` and `page`. + +*Exceeding the `page`/`limit` of all available records will not return `404`, it will return an +empty response.*" + }; +} +pub(crate) use limit_description; +/// Description +pub(crate) const LIMIT_DESCRIPTION: &str = limit_description!(); +/// Example. +const LIMIT_EXAMPLE: u64 = 10; +/// Default Limit (Should be used by paged responses to set the maximum size of the +/// response). +pub(crate) const LIMIT_DEFAULT: u64 = 100; +/// Minimum. +const LIMIT_MINIMUM: u64 = 1; +/// Maximum. +const LIMIT_MAXIMUM: u64 = LIMIT_DEFAULT; + +/// Schema. +#[allow(clippy::cast_precision_loss)] +static LIMIT_SCHEMA: LazyLock = LazyLock::new(|| { + MetaSchema { + title: Some(LIMIT_TITLE.to_owned()), + description: Some(LIMIT_DESCRIPTION), + example: Some(LIMIT_EXAMPLE.into()), + default: Page(LIMIT_DEFAULT).to_json(), + maximum: Some(LIMIT_MAXIMUM as f64), + minimum: Some(LIMIT_MINIMUM as f64), + ..poem_openapi::registry::MetaSchema::ANY + } +}); + +/// Limit of items to be returned in a page of data. +#[derive(Debug, Eq, PartialEq, Hash)] +pub(crate) struct Limit(u64); + +impl Default for Limit { + fn default() -> Self { + Self(LIMIT_DEFAULT) + } +} + +/// Is the `Page` valid? +fn is_valid_limit(value: u64) -> bool { + (LIMIT_MINIMUM..=LIMIT_MAXIMUM).contains(&value) +} + +impl Type for Limit { + type RawElementValueType = Self; + type RawValueType = Self; + + const IS_REQUIRED: bool = true; + + fn name() -> std::borrow::Cow<'static, str> { + "integer(u64)".into() + } + + fn schema_ref() -> MetaSchemaRef { + let schema_ref = + MetaSchemaRef::Inline(Box::new(MetaSchema::new_with_format("integer", "u64"))); + schema_ref.merge(LIMIT_SCHEMA.clone()) + } + + fn as_raw_value(&self) -> Option<&Self::RawValueType> { + Some(self) + } + + fn raw_element_iter<'a>( + &'a self, + ) -> Box + 'a> { + Box::new(self.as_raw_value().into_iter()) + } +} + +impl ParseFromParameter for Limit { + fn parse_from_parameter(value: &str) -> ParseResult { + let limit: u64 = value.parse()?; + Ok(Limit(limit)) + } +} + +impl ParseFromJSON for Limit { + fn parse_from_json(value: Option) -> ParseResult { + let value = value.unwrap_or_default(); + if let Value::Number(value) = value { + let value = value.as_u64().unwrap_or_default(); + if !is_valid_limit(value) { + return Err("invalid Limit".into()); + } + Ok(Self(value)) + } else { + Err(ParseError::expected_type(value)) + } + } +} + +impl ToJSON for Limit { + fn to_json(&self) -> Option { + Some(self.0.into()) + } +} + +impl From for Limit { + fn from(value: u64) -> Self { + Self(value) + } +} + +impl Example for Limit { + fn example() -> Self { + Self(LIMIT_EXAMPLE) + } +} + +//***** REMAINING : Not a Query Parameter, but tightly coupled type used in the pagination +//***** response. */ +/// Title. +const REMAINING_TITLE: &str = "Remaining"; +/// Description. +macro_rules! remaining_description { + () => { + "The number of items remaining to be returned after this page. +This is the absolute number of items remaining, and not the number of Pages." + }; +} +pub(crate) use remaining_description; +/// Description +pub(crate) const REMAINING_DESCRIPTION: &str = remaining_description!(); +/// Example. +const REMAINING_EXAMPLE: u64 = 16_384; +/// Minimum. +const REMAINING_MINIMUM: u64 = 0; +/// Maximum. +const REMAINING_MAXIMUM: u64 = u64::MAX; + +/// Schema. +#[allow(clippy::cast_precision_loss)] +static REMAINING_SCHEMA: LazyLock = LazyLock::new(|| { + MetaSchema { + title: Some(REMAINING_TITLE.to_owned()), + description: Some(REMAINING_DESCRIPTION), + example: Some(REMAINING_EXAMPLE.into()), + maximum: Some(REMAINING_MAXIMUM as f64), + minimum: Some(REMAINING_MINIMUM as f64), + ..poem_openapi::registry::MetaSchema::ANY + } +}); + +/// Limit of items to be returned in a page of data. +#[derive(Debug, Eq, PartialEq, Hash)] +pub(crate) struct Remaining(u64); + +/// Is the `Page` valid? +fn is_valid_remaining(value: u64) -> bool { + (REMAINING_MINIMUM..=REMAINING_MAXIMUM).contains(&value) +} + +impl Type for Remaining { + type RawElementValueType = Self; + type RawValueType = Self; + + const IS_REQUIRED: bool = true; + + fn name() -> std::borrow::Cow<'static, str> { + "integer(u64)".into() + } + + fn schema_ref() -> MetaSchemaRef { + let schema_ref = + MetaSchemaRef::Inline(Box::new(MetaSchema::new_with_format("integer", "u64"))); + schema_ref.merge(REMAINING_SCHEMA.clone()) + } + + fn as_raw_value(&self) -> Option<&Self::RawValueType> { + Some(self) + } + + fn raw_element_iter<'a>( + &'a self, + ) -> Box + 'a> { + Box::new(self.as_raw_value().into_iter()) + } +} + +impl ParseFromJSON for Remaining { + fn parse_from_json(value: Option) -> ParseResult { + let value = value.unwrap_or_default(); + if let Value::Number(value) = value { + let value = value.as_u64().unwrap_or_default(); + if !is_valid_remaining(value) { + return Err("invalid Remaining".into()); + } + Ok(Self(value)) + } else { + Err(ParseError::expected_type(value)) + } + } +} + +impl ToJSON for Remaining { + fn to_json(&self) -> Option { + Some(self.0.into()) + } +} + +impl From for Remaining { + fn from(value: u64) -> Self { + Self(value) + } +} + +impl Example for Remaining { + fn example() -> Self { + Self(REMAINING_EXAMPLE) + } +} diff --git a/catalyst-gateway/bin/src/service/common/types/string_types.rs b/catalyst-gateway/bin/src/service/common/types/string_types.rs index 8f65629697d..5655fe9dd83 100644 --- a/catalyst-gateway/bin/src/service/common/types/string_types.rs +++ b/catalyst-gateway/bin/src/service/common/types/string_types.rs @@ -32,11 +32,11 @@ /// impl for MyNewType { ... } /// ``` macro_rules! impl_string_types { - ($(#[$docs:meta])* $ty:ident, $type_name:literal, $format:literal, $schema:expr ) => { + ($(#[$docs:meta])* $ty:ident, $type_name:literal, $format:expr, $schema:expr ) => { impl_string_types!($(#[$docs])* $ty, $type_name, $format, $schema, |_| true); }; - ($(#[$docs:meta])* $ty:ident, $type_name:literal, $format:literal, $schema:expr, $validator:expr) => { + ($(#[$docs:meta])* $ty:ident, $type_name:literal, $format:expr, $schema:expr, $validator:expr) => { $(#[$docs])* #[derive(Debug, Clone, Eq, PartialEq, Hash)] pub(crate) struct $ty(String); @@ -69,7 +69,7 @@ macro_rules! impl_string_types { type RawElementValueType = Self; fn name() -> Cow<'static, str> { - concat!($type_name, "(", $format, ")").into() + format!("{}({})", $type_name, $format).into() } fn schema_ref() -> MetaSchemaRef { @@ -103,7 +103,7 @@ macro_rules! impl_string_types { if let Value::String(value) = value { let validator = $validator; if !validator(&value) { - return Err(concat!("invalid ", $format).into()); + return Err(format!("invalid {}", $format).into()); } Ok(Self(value)) } else { @@ -116,7 +116,7 @@ macro_rules! impl_string_types { fn parse_from_parameter(value: &str) -> ParseResult { let validator = $validator; if !validator(value) { - return Err(concat!("invalid ", $format).into()); + return Err(format!("invalid {}", $format).into()); } Ok(Self(value.to_string())) } diff --git a/catalyst-gateway/bin/src/service/utilities/mod.rs b/catalyst-gateway/bin/src/service/utilities/mod.rs index 796aca69224..94990a2ec2f 100644 --- a/catalyst-gateway/bin/src/service/utilities/mod.rs +++ b/catalyst-gateway/bin/src/service/utilities/mod.rs @@ -4,16 +4,30 @@ pub(crate) mod convert; pub(crate) mod middleware; pub(crate) mod net; -use pallas::ledger::addresses::Network as PallasNetwork; -use poem_openapi::types::ToJSON; +use anyhow::{bail, Result}; +// use pallas::ledger::addresses::Network as PallasNetwork; +// use poem_openapi::types::ToJSON; -use crate::service::common::objects::cardano::network::Network; +// use crate::service::common::objects::cardano::network::Network; /// Convert bytes to hex string with the `0x` prefix -pub(crate) fn to_hex_with_prefix(bytes: &[u8]) -> String { +pub(crate) fn as_hex_string(bytes: &[u8]) -> String { format!("0x{}", hex::encode(bytes)) } +/// Convert bytes to hex string with the `0x` prefix +pub(crate) fn from_hex_string(hex: &str) -> Result> { + #[allow(clippy::string_slice)] // Safe because of size checks. + if hex.len() < 4 || hex.len() % 2 != 0 || &hex[0..2] != "0x" { + bail!("Invalid hex string"); + } + + #[allow(clippy::string_slice)] // Safe due to above checks. + Ok(hex::decode(&hex[2..])?) +} + +/// Unused +const _UNUSED: &str = r#" /// Network validation error #[derive(thiserror::Error, Debug)] pub(crate) enum NetworkValidationError { @@ -63,3 +77,4 @@ pub(crate) fn check_network( PallasNetwork::Other(x) => Err(NetworkValidationError::UnknownNetwork(x).into()), } } +"#; diff --git a/catalyst-gateway/bin/src/settings/mod.rs b/catalyst-gateway/bin/src/settings/mod.rs index 2021501d8d8..28726514680 100644 --- a/catalyst-gateway/bin/src/settings/mod.rs +++ b/catalyst-gateway/bin/src/settings/mod.rs @@ -8,6 +8,7 @@ use std::{ }; use anyhow::anyhow; +use cardano_chain_follower::Network; use clap::Args; use dotenvy::dotenv; use duration_string::DurationString; @@ -270,6 +271,12 @@ impl Settings { ENV_VARS.chain_follower.clone() } + /// Chain Follower network (The Blockchain network we are configured to use). + /// Note: Catalyst Gateway can ONLY follow one network at a time. + pub(crate) fn cardano_network() -> Network { + ENV_VARS.chain_follower.chain + } + /// The API Url prefix pub(crate) fn api_url_prefix() -> &'static str { ENV_VARS.api_url_prefix.as_str() diff --git a/catalyst-gateway/rustfmt.toml b/catalyst-gateway/rustfmt.toml index fa6d8c2e906..905bde2d0bd 100644 --- a/catalyst-gateway/rustfmt.toml +++ b/catalyst-gateway/rustfmt.toml @@ -65,4 +65,4 @@ condense_wildcard_suffixes = true hex_literal_case = "Upper" # Ignored files: -ignore = [] \ No newline at end of file +ignore = [] diff --git a/catalyst-gateway/tests/Earthfile b/catalyst-gateway/tests/Earthfile index 979773202f8..85383a20403 100644 --- a/catalyst-gateway/tests/Earthfile +++ b/catalyst-gateway/tests/Earthfile @@ -9,6 +9,6 @@ test-lint-openapi: # Copy the doc artifact. COPY --dir ../+build/doc . # Copy the spectral configuration file. - COPY ./.oapi-v3.spectral.yml .spectral.yml + COPY --dir ./openapi-v3.0-lints/* . # Scan the doc directory where type of file is JSON. DO spectral-ci+LINT --dir=./doc diff --git a/catalyst-gateway/tests/.oapi-v3.spectral.yml b/catalyst-gateway/tests/openapi-v3.0-lints/.spectral.yml similarity index 93% rename from catalyst-gateway/tests/.oapi-v3.spectral.yml rename to catalyst-gateway/tests/openapi-v3.0-lints/.spectral.yml index 9d8710175cc..3470dbb1204 100644 --- a/catalyst-gateway/tests/.oapi-v3.spectral.yml +++ b/catalyst-gateway/tests/openapi-v3.0-lints/.spectral.yml @@ -13,6 +13,10 @@ extends: formats: ["oas3"] +functions: + - "debug" + - "description-required" + aliases: # From: https://github.com/stoplightio/spectral-owasp-ruleset/blob/26819e80e5ac4571b6271834fc97f0a1b66110bd/src/ruleset.ts#L60 StringProperties: @@ -74,6 +78,8 @@ overrides: owasp:api3:2023-no-additionalProperties: error owasp:api3:2023-constrained-additionalProperties: error owasp:api2:2023-read-restricted: error + # Replaced by custom rule `description-required` + oas3-parameter-description: off # Not enforced at OpenAPI level. Production URL's will always be https. owasp:api8:2023-no-server-http: off # Can't add custom properties to server list. @@ -174,23 +180,9 @@ rules: severity: error given: "#DescribableObjects" then: - - field: "description" - function: "truthy" - - field: "description" - function: "length" - functionOptions: - min: 20 - - field: "description" - function: "pattern" - functionOptions: - # Matches any character that is #, *, uppercase or lowercase letters from A to Z, or digits from 0 to 9 at the beginning of the string. - # with zero or more occurrences of any character except newline. - match: "^[#*A-Za-z0-9].*" - - field: "description" - function: pattern - functionOptions: - # Matches against a full stop or a literal `*` at the end of a description. - match: "[\\.\\*]$" + function: description-required + functionOptions: + length: 20 api-path: message: "Invalid API path - should be /api/draft/* or /api/v/*" diff --git a/catalyst-gateway/tests/openapi-v3.0-lints/functions/debug.js b/catalyst-gateway/tests/openapi-v3.0-lints/functions/debug.js new file mode 100644 index 00000000000..d57c01a1e6b --- /dev/null +++ b/catalyst-gateway/tests/openapi-v3.0-lints/functions/debug.js @@ -0,0 +1,28 @@ +// Debug target. +// Always fails, message is all the parameters it received. +import { createRulesetFunction } from "@stoplight/spectral-core"; + +export default createRulesetFunction( + { + input: null, + options: { + type: 'object', + properties: { + context: { + type: 'boolean', + description: 'Debug print the context', + default: false + }, + }, + additionalProperties: true + } + }, + (input, options, context) => { + console.log('------ DEBUG ----------------------------------------------------------------') + console.log('input', input); + console.log('options', options); + if (options.context) { + console.log('context', context); + } + }, +); \ No newline at end of file diff --git a/catalyst-gateway/tests/openapi-v3.0-lints/functions/description-required.js b/catalyst-gateway/tests/openapi-v3.0-lints/functions/description-required.js new file mode 100644 index 00000000000..effabdf9e29 --- /dev/null +++ b/catalyst-gateway/tests/openapi-v3.0-lints/functions/description-required.js @@ -0,0 +1,117 @@ +import { createRulesetFunction } from "@stoplight/spectral-core"; +import { printValue } from '@stoplight/spectral-runtime'; + +// regex in a string like {"match": "/[a-b]+/im"} or {"match": "[a-b]+"} in a json ruleset +// the available flags are "gimsuy" as described here: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp +const REGEXP_PATTERN = /^\/(.+)\/([a-z]*)$/; + +const cache = new Map(); + +function getFromCache(pattern) { + const existingPattern = cache.get(pattern); + if (existingPattern !== void 0) { + existingPattern.lastIndex = 0; + return existingPattern; + } + + const newPattern = createRegex(pattern); + cache.set(pattern, newPattern); + return newPattern; +} + +function createRegex(pattern) { + const splitRegex = REGEXP_PATTERN.exec(pattern); + if (splitRegex !== null) { + // with slashes like /[a-b]+/ and possibly with flags like /[a-b]+/im + return new RegExp(splitRegex[1], splitRegex[2]); + } else { + // without slashes like [a-b]+ + return new RegExp(pattern); + } +} + +export default createRulesetFunction( + { + input: null, + options: { + type: 'object', + properties: { + length: { + type: 'integer', + description: 'The minimum length of a description.', + }, + match: { + type: 'string', + description: 'regex that target must match.', + }, + noMatch: { + type: 'string', + description: 'regex that target must not match.', + }, + }, + additionalProperties: false, + }, + }, + (input, options, context) => { + let results = []; + const { } = options; + + const testDescriptionValidity = (value) => { + if (!value) { + (results ??= []).push({ + message: `Description must exist`, + }); + } + + if ('length' in options) { + if (value.length < options.length) { + (results ??= []).push({ + message: `Description must have length >= ${printValue(options.length)} characters`, + }) + } + } + + if ('match' in options) { + const pattern = getFromCache(options.match); + + if (!pattern.test(value)) { + (results ??= []).push({ + message: `${printValue(value)} must match the pattern ${printValue(options.match)}`, + }) + } + } + + if ('noMatch' in options) { + const pattern = getFromCache(options.noMatch); + + if (pattern.test(value)) { + (results ??= []).push({ + message: `${printValue(value)} must NOT match the pattern ${printValue(options.noMatch)}`, + }) + } + } + }; + + // check if 'description' or 'schema.description' exists in the ParameterObject + if (input.description) { + testDescriptionValidity(input.description); + } else if ("in" in input && input.in === "query") { + if ("schema" in input && "description" in input.schema) { + testDescriptionValidity(input.schema.description); + } else { + (results ??= []).push({ + message: `'description' or 'schema.description' is missing in the Query Parameter.` + }) + } + } + else { + (results ??= []).push({ + message: `'description' is missing.` + }) + } + + if (results.length) { + return results; + } + }, +); diff --git a/docs/src/api/cat-gateway/stoplight_template.html b/docs/src/api/cat-gateway/stoplight_template.html index 582d1db5a62..d0c263bc938 100644 --- a/docs/src/api/cat-gateway/stoplight_template.html +++ b/docs/src/api/cat-gateway/stoplight_template.html @@ -22,9 +22,10 @@ Catalyst Gateway Rust Docs - - + + From 96daec376ba30037b6520418bca612f33faca03a Mon Sep 17 00:00:00 2001 From: Ryszard Schossler <51096731+LynxLynxx@users.noreply.github.com> Date: Tue, 26 Nov 2024 12:08:36 +0100 Subject: [PATCH 07/17] refactor(cat-voices): Changing NoProposals widget to EmptyState (#1260) * making noProposals as empty state * test: adding test for custom image * Update catalyst_voices/apps/voices/test/widgets/empty_state/empty_state_test.dart Co-authored-by: Dominik Toton <166132265+dtscalac@users.noreply.github.com> --------- Co-authored-by: Dominik Toton <166132265+dtscalac@users.noreply.github.com> --- .../empty_state.dart} | 40 +++++++++++-------- .../widgets/images/voices_image_scheme.dart | 4 +- .../empty_state_test.dart} | 27 ++++++++++--- 3 files changed, 46 insertions(+), 25 deletions(-) rename catalyst_voices/apps/voices/lib/widgets/{proposals/no_proposals.dart => empty_state/empty_state.dart} (71%) rename catalyst_voices/apps/voices/test/widgets/{proposals/no_proposals_test.dart => empty_state/empty_state_test.dart} (81%) diff --git a/catalyst_voices/apps/voices/lib/widgets/proposals/no_proposals.dart b/catalyst_voices/apps/voices/lib/widgets/empty_state/empty_state.dart similarity index 71% rename from catalyst_voices/apps/voices/lib/widgets/proposals/no_proposals.dart rename to catalyst_voices/apps/voices/lib/widgets/empty_state/empty_state.dart index a3362192e5a..a2bb1e959b1 100644 --- a/catalyst_voices/apps/voices/lib/widgets/proposals/no_proposals.dart +++ b/catalyst_voices/apps/voices/lib/widgets/empty_state/empty_state.dart @@ -4,14 +4,18 @@ import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; import 'package:flutter/material.dart'; -class NoProposals extends StatelessWidget { +class EmptyState extends StatelessWidget { final String? title; final String? description; + final Widget? image; + final Widget? imageBackground; - const NoProposals({ + const EmptyState({ super.key, this.title, this.description, + this.image, + this.imageBackground, }); @override @@ -23,18 +27,20 @@ class NoProposals extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: 64), child: Column( children: [ - VoicesImagesScheme( - image: CatalystSvgPicture.asset( - VoicesAssets.images.noProposalForeground.path, - ), - background: Container( - height: 180, - decoration: BoxDecoration( - color: theme.colors.onSurfaceNeutral08, - shape: BoxShape.circle, + image ?? + VoicesImagesScheme( + image: CatalystSvgPicture.asset( + VoicesAssets.images.noProposalForeground.path, + ), + background: imageBackground ?? + Container( + height: 180, + decoration: BoxDecoration( + color: theme.colors.onSurfaceNeutral08, + shape: BoxShape.circle, + ), + ), ), - ), - ), const SizedBox(height: 24), SizedBox( width: 430, @@ -61,11 +67,11 @@ class NoProposals extends StatelessWidget { ); } - String _buildTitle(BuildContext context) { - return title ?? context.l10n.noProposalStateTitle; - } - String _buildDescription(BuildContext context) { return description ?? context.l10n.noProposalStateDescription; } + + String _buildTitle(BuildContext context) { + return title ?? context.l10n.noProposalStateTitle; + } } diff --git a/catalyst_voices/apps/voices/lib/widgets/images/voices_image_scheme.dart b/catalyst_voices/apps/voices/lib/widgets/images/voices_image_scheme.dart index 04a77bb328f..ddf0bf9bce3 100644 --- a/catalyst_voices/apps/voices/lib/widgets/images/voices_image_scheme.dart +++ b/catalyst_voices/apps/voices/lib/widgets/images/voices_image_scheme.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; class VoicesImagesScheme extends StatelessWidget { final Widget image; - final Widget background; + final Widget? background; const VoicesImagesScheme({ super.key, @@ -15,7 +15,7 @@ class VoicesImagesScheme extends StatelessWidget { return Stack( alignment: Alignment.center, children: [ - background, + if (background != null) background!, image, ], ); diff --git a/catalyst_voices/apps/voices/test/widgets/proposals/no_proposals_test.dart b/catalyst_voices/apps/voices/test/widgets/empty_state/empty_state_test.dart similarity index 81% rename from catalyst_voices/apps/voices/test/widgets/proposals/no_proposals_test.dart rename to catalyst_voices/apps/voices/test/widgets/empty_state/empty_state_test.dart index 78e7f7550e0..d19e3df0b8b 100644 --- a/catalyst_voices/apps/voices/test/widgets/proposals/no_proposals_test.dart +++ b/catalyst_voices/apps/voices/test/widgets/empty_state/empty_state_test.dart @@ -1,4 +1,5 @@ -import 'package:catalyst_voices/widgets/proposals/no_proposals.dart'; +import 'package:catalyst_voices/widgets/empty_state/empty_state.dart'; +import 'package:catalyst_voices/widgets/images/voices_image_scheme.dart'; import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; import 'package:flutter/material.dart'; @@ -7,10 +8,10 @@ import 'package:flutter_test/flutter_test.dart'; import '../../helpers/helpers.dart'; void main() { - group('NoProposals Widget Tests', () { + group('EmptyState Widget Tests', () { testWidgets('Renders correctly with default values', (tester) async { await tester.pumpApp( - const NoProposals(), + const EmptyState(), ); await tester.pumpAndSettle(); @@ -28,7 +29,7 @@ void main() { testWidgets('Renders correctly with custom values', (tester) async { await tester.pumpApp( - const NoProposals( + const EmptyState( title: 'Custom Title', description: 'Custom Description', ), @@ -45,7 +46,7 @@ void main() { VoicesColorScheme.optional(textOnPrimaryLevel1: Colors.red); await tester.pumpApp( voicesColors: colors, - const NoProposals( + const EmptyState( title: 'Custom Title', description: 'Custom Description', ), @@ -75,7 +76,7 @@ void main() { 'Proposal image changes depending on theme brightness', (tester) async { // Given - const widget = NoProposals(); + const widget = EmptyState(); // When - Light theme await tester.pumpApp( @@ -112,5 +113,19 @@ void main() { ); }, ); + + testWidgets('Renders correctly with custom image', (tester) async { + await tester.pumpApp( + EmptyState( + image: CatalystSvgPicture.asset( + VoicesAssets.images.noProposalForeground.path, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(CatalystSvgPicture), findsOneWidget); + expect(find.byType(VoicesImagesScheme), findsNothing); + }); }); } From 31d2e8d6ca23ddeb6913cf8747d415ec7ef6695f Mon Sep 17 00:00:00 2001 From: minikin Date: Tue, 26 Nov 2024 13:19:18 +0100 Subject: [PATCH 08/17] Revert "Merge branch 'mve3' into main" This reverts commit 01db066663ece91c2c5f6ab3150387803f87885c, reversing changes made to 3bf0ccf6cbc38359e888e53cb24ada753b0f2ebc. --- .../lib/common/formatters/date_formatter.dart | 29 +-- .../pages/account/unlock_keychain_dialog.dart | 1 - .../workspace/workspace_guidance_view.dart | 39 ++-- .../lib/widgets/empty_state/empty_state.dart | 77 ------ .../widgets/images/voices_image_scheme.dart | 23 -- .../voices/lib/widgets/menu/voices_menu.dart | 34 +-- .../lib/widgets/menu/voices_modal_menu.dart | 179 -------------- .../campaign/campaign_categories_tile.dart | 165 ------------- .../campaign/campaign_details_tile.dart | 219 ------------------ .../voices_password_text_field.dart | 5 - .../widgets/text_field/voices_text_field.dart | 5 - .../widgets/tiles/voices_expansion_tile.dart | 107 --------- .../apps/voices/lib/widgets/widgets.dart | 2 - .../formatters/date_formatter_test.dart | 115 +++------ .../widgets/empty_state/empty_state_test.dart | 131 ----------- .../test/widgets/menu/voices_menu_test.dart | 16 +- .../assets/images/no_proposal_foreground.svg | 41 ---- .../lib/l10n/intl_en.arb | 27 +-- .../lib/src/keychain/vault_keychain.dart | 3 + .../storage/vault/secure_storage_vault.dart | 14 +- .../lib/src/storage/vault/vault.dart | 4 +- .../vault_keychain_provider_test.dart | 5 +- .../src/keychain/vault_keychain_test.dart | 4 +- .../test/src/user/user_service_test.dart | 10 +- .../lib/src/campaign/campaign_category.dart | 14 -- .../lib/src/campaign/campaign_section.dart | 31 --- .../lib/src/catalyst_voices_view_models.dart | 4 - .../lib/src/menu/menu_item.dart | 31 --- .../lib/src/menu/popup_menu_item.dart | 32 --- .../lib/examples/voices_menu_example.dart | 30 +-- 30 files changed, 120 insertions(+), 1277 deletions(-) delete mode 100644 catalyst_voices/apps/voices/lib/widgets/empty_state/empty_state.dart delete mode 100644 catalyst_voices/apps/voices/lib/widgets/images/voices_image_scheme.dart delete mode 100644 catalyst_voices/apps/voices/lib/widgets/menu/voices_modal_menu.dart delete mode 100644 catalyst_voices/apps/voices/lib/widgets/modals/campaign/campaign_categories_tile.dart delete mode 100644 catalyst_voices/apps/voices/lib/widgets/modals/campaign/campaign_details_tile.dart delete mode 100644 catalyst_voices/apps/voices/lib/widgets/tiles/voices_expansion_tile.dart delete mode 100644 catalyst_voices/apps/voices/test/widgets/empty_state/empty_state_test.dart delete mode 100644 catalyst_voices/packages/internal/catalyst_voices_assets/assets/images/no_proposal_foreground.svg delete mode 100644 catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_category.dart delete mode 100644 catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_section.dart delete mode 100644 catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/menu/menu_item.dart delete mode 100644 catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/menu/popup_menu_item.dart diff --git a/catalyst_voices/apps/voices/lib/common/formatters/date_formatter.dart b/catalyst_voices/apps/voices/lib/common/formatters/date_formatter.dart index 5012fc0ca93..0829da4ce11 100644 --- a/catalyst_voices/apps/voices/lib/common/formatters/date_formatter.dart +++ b/catalyst_voices/apps/voices/lib/common/formatters/date_formatter.dart @@ -10,14 +10,10 @@ abstract class DateFormatter { /// - Yesterday /// - 2 days ago /// - Other cases: yMMMMd date format. - static String formatRecentDate( - VoicesLocalizations l10n, - DateTime dateTime, { - DateTime? from, - }) { - from ??= DateTimeExt.now(); + static String formatRecentDate(VoicesLocalizations l10n, DateTime dateTime) { + final now = DateTimeExt.now(); - final today = DateTime(from.year, from.month, from.day, 12); + final today = DateTime(now.year, now.month, now.day, 12); if (dateTime.isSameDateAs(today)) return l10n.today; final tomorrow = today.plusDays(1); @@ -31,23 +27,4 @@ abstract class DateFormatter { return DateFormat.yMMMMd().format(dateTime); } - - static String formatInDays( - VoicesLocalizations l10n, - DateTime dateTime, { - DateTime? from, - }) { - from ??= DateTimeExt.now(); - - final days = dateTime.isAfter(from) ? dateTime.difference(from).inDays : 0; - - return l10n.inXDays(days); - } - - static String formatShortMonth( - VoicesLocalizations l10n, - DateTime dateTime, - ) { - return DateFormat.MMM().format(dateTime); - } } diff --git a/catalyst_voices/apps/voices/lib/pages/account/unlock_keychain_dialog.dart b/catalyst_voices/apps/voices/lib/pages/account/unlock_keychain_dialog.dart index deb0a32412e..2c3b3385e45 100644 --- a/catalyst_voices/apps/voices/lib/pages/account/unlock_keychain_dialog.dart +++ b/catalyst_voices/apps/voices/lib/pages/account/unlock_keychain_dialog.dart @@ -155,7 +155,6 @@ class _UnlockPassword extends StatelessWidget { Widget build(BuildContext context) { return VoicesPasswordTextField( controller: controller, - autofocus: true, decoration: VoicesTextFieldDecoration( labelText: context.l10n.unlockDialogHint, errorText: error?.message(context), diff --git a/catalyst_voices/apps/voices/lib/pages/workspace/workspace_guidance_view.dart b/catalyst_voices/apps/voices/lib/pages/workspace/workspace_guidance_view.dart index 279aabae2b2..fd580e3ac84 100644 --- a/catalyst_voices/apps/voices/lib/pages/workspace/workspace_guidance_view.dart +++ b/catalyst_voices/apps/voices/lib/pages/workspace/workspace_guidance_view.dart @@ -7,7 +7,6 @@ import 'package:flutter/material.dart'; class GuidanceView extends StatefulWidget { final List guidances; - const GuidanceView(this.guidances, {super.key}); @override @@ -19,6 +18,25 @@ class _GuidanceViewState extends State { GuidanceType? selectedType; + @override + void initState() { + super.initState(); + filteredGuidances + ..clear() + ..addAll(widget.guidances); + } + + @override + void didUpdateWidget(GuidanceView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.guidances != widget.guidances) { + filteredGuidances + ..clear() + ..addAll(widget.guidances); + _filterGuidances(selectedType); + } + } + @override Widget build(BuildContext context) { return Column( @@ -56,25 +74,6 @@ class _GuidanceViewState extends State { ); } - @override - void didUpdateWidget(GuidanceView oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.guidances != widget.guidances) { - filteredGuidances - ..clear() - ..addAll(widget.guidances); - _filterGuidances(selectedType); - } - } - - @override - void initState() { - super.initState(); - filteredGuidances - ..clear() - ..addAll(widget.guidances); - } - void _filterGuidances(GuidanceType? type) { selectedType = type; filteredGuidances diff --git a/catalyst_voices/apps/voices/lib/widgets/empty_state/empty_state.dart b/catalyst_voices/apps/voices/lib/widgets/empty_state/empty_state.dart deleted file mode 100644 index a2bb1e959b1..00000000000 --- a/catalyst_voices/apps/voices/lib/widgets/empty_state/empty_state.dart +++ /dev/null @@ -1,77 +0,0 @@ -import 'package:catalyst_voices/widgets/images/voices_image_scheme.dart'; -import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; -import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; -import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; -import 'package:flutter/material.dart'; - -class EmptyState extends StatelessWidget { - final String? title; - final String? description; - final Widget? image; - final Widget? imageBackground; - - const EmptyState({ - super.key, - this.title, - this.description, - this.image, - this.imageBackground, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final textTheme = theme.textTheme; - return Center( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 64), - child: Column( - children: [ - image ?? - VoicesImagesScheme( - image: CatalystSvgPicture.asset( - VoicesAssets.images.noProposalForeground.path, - ), - background: imageBackground ?? - Container( - height: 180, - decoration: BoxDecoration( - color: theme.colors.onSurfaceNeutral08, - shape: BoxShape.circle, - ), - ), - ), - const SizedBox(height: 24), - SizedBox( - width: 430, - child: Column( - children: [ - Text( - _buildTitle(context), - style: textTheme.titleMedium - ?.copyWith(color: theme.colors.textOnPrimaryLevel1), - ), - const SizedBox(height: 8), - Text( - _buildDescription(context), - style: textTheme.bodyMedium - ?.copyWith(color: theme.colors.textOnPrimaryLevel1), - textAlign: TextAlign.center, - ), - ], - ), - ), - ], - ), - ), - ); - } - - String _buildDescription(BuildContext context) { - return description ?? context.l10n.noProposalStateDescription; - } - - String _buildTitle(BuildContext context) { - return title ?? context.l10n.noProposalStateTitle; - } -} diff --git a/catalyst_voices/apps/voices/lib/widgets/images/voices_image_scheme.dart b/catalyst_voices/apps/voices/lib/widgets/images/voices_image_scheme.dart deleted file mode 100644 index ddf0bf9bce3..00000000000 --- a/catalyst_voices/apps/voices/lib/widgets/images/voices_image_scheme.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:flutter/material.dart'; - -class VoicesImagesScheme extends StatelessWidget { - final Widget image; - final Widget? background; - - const VoicesImagesScheme({ - super.key, - required this.image, - required this.background, - }); - - @override - Widget build(BuildContext context) { - return Stack( - alignment: Alignment.center, - children: [ - if (background != null) background!, - image, - ], - ); - } -} diff --git a/catalyst_voices/apps/voices/lib/widgets/menu/voices_menu.dart b/catalyst_voices/apps/voices/lib/widgets/menu/voices_menu.dart index 178e9bbccc8..0a98b997093 100644 --- a/catalyst_voices/apps/voices/lib/widgets/menu/voices_menu.dart +++ b/catalyst_voices/apps/voices/lib/widgets/menu/voices_menu.dart @@ -1,5 +1,4 @@ import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; -import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; import 'package:flutter/material.dart'; /// A menu of the app that @@ -69,7 +68,7 @@ class _MenuButton extends StatelessWidget { final textStyle = textTheme.bodyMedium?.copyWith( color: - menuItem.isEnabled ? textTheme.bodySmall?.color : theme.disabledColor, + menuItem.enabled ? textTheme.bodySmall?.color : theme.disabledColor, ); final children = menuChildren; @@ -86,7 +85,7 @@ class _MenuButton extends StatelessWidget { child: IconTheme( data: IconThemeData( size: 24, - color: menuItem.isEnabled + color: menuItem.enabled ? textTheme.bodySmall?.color : theme.disabledColor, ), @@ -139,29 +138,32 @@ class _MenuButton extends StatelessWidget { } /// Model representing Menu Item -final class MenuItem extends BasicPopupMenuItem { - const MenuItem({ - required super.id, - required super.label, - super.isEnabled = true, - super.icon, - super.showDivider = false, +class MenuItem { + final int id; + final String label; + final Widget? icon; + final bool showDivider; + final bool enabled; + + MenuItem({ + required this.id, + required this.label, + this.icon, + this.showDivider = false, + this.enabled = true, }); } /// Model representing Submenu Item /// and extending from MenuItem -final class SubMenuItem extends MenuItem { - final List children; +class SubMenuItem extends MenuItem { + List children; - const SubMenuItem({ + SubMenuItem({ required super.id, required super.label, required this.children, super.icon, super.showDivider, }); - - @override - List get props => super.props + [children]; } diff --git a/catalyst_voices/apps/voices/lib/widgets/menu/voices_modal_menu.dart b/catalyst_voices/apps/voices/lib/widgets/menu/voices_modal_menu.dart deleted file mode 100644 index 61b24038779..00000000000 --- a/catalyst_voices/apps/voices/lib/widgets/menu/voices_modal_menu.dart +++ /dev/null @@ -1,179 +0,0 @@ -import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; -import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; -import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; -import 'package:flutter/material.dart'; - -class VoicesModalMenu extends StatelessWidget { - final String? selectedId; - final List menuItems; - final ValueChanged? onTap; - - const VoicesModalMenu({ - super.key, - this.selectedId, - required this.menuItems, - this.onTap, - }); - - @override - Widget build(BuildContext context) { - final onTap = this.onTap; - - return Column( - mainAxisSize: MainAxisSize.min, - children: menuItems - .map( - (item) { - return _VoicesModalMenuItemTile( - key: ValueKey('VoicesModalMenu[${item.id}]Key'), - label: item.label, - isSelected: selectedId == item.id, - isEnabled: item.isEnabled, - onTap: onTap != null ? () => onTap(item.id) : null, - ); - }, - ) - .separatedBy(const SizedBox(height: 8)) - .toList(), - ); - } -} - -class _VoicesModalMenuItemTile extends StatefulWidget { - final String label; - final bool isSelected; - final bool isEnabled; - final VoidCallback? onTap; - - const _VoicesModalMenuItemTile({ - required super.key, - required this.label, - required this.isSelected, - required this.isEnabled, - this.onTap, - }); - - @override - State<_VoicesModalMenuItemTile> createState() { - return _VoicesModalMenuItemTileState(); - } -} - -class _VoicesModalMenuItemTileState extends State<_VoicesModalMenuItemTile> { - late _BackgroundColor _backgroundColor; - late _ForegroundColor _foregroundColor; - late _LabelTextStyle _labelTextStyle; - late _BorderColor _border; - - Set get _states => { - if (!widget.isEnabled) WidgetState.disabled, - if (widget.isSelected) WidgetState.selected, - }; - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - - final theme = Theme.of(context); - - _backgroundColor = _BackgroundColor(theme.colorScheme.brightness); - _foregroundColor = _ForegroundColor(theme.colors); - _labelTextStyle = _LabelTextStyle(theme.textTheme); - _border = _BorderColor(theme.colorScheme.brightness); - } - - @override - Widget build(BuildContext context) { - return InkWell( - onTap: widget.isEnabled ? widget.onTap : null, - borderRadius: BorderRadius.circular(8), - child: AnimatedContainer( - duration: const Duration(milliseconds: 250), - constraints: const BoxConstraints(minWidth: 320), - decoration: BoxDecoration( - color: _backgroundColor.resolve(_states), - border: _border.resolve(_states), - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16) - .add(const EdgeInsets.only(bottom: 2)), - child: Text( - widget.label, - overflow: TextOverflow.ellipsis, - maxLines: 1, - style: _labelTextStyle - .resolve(_states) - .copyWith(color: _foregroundColor.resolve(_states)), - ), - ), - ); - } -} - -class _BackgroundColor extends WidgetStateProperty { - final Brightness _brightness; - - _BackgroundColor(this._brightness); - - @override - Color? resolve(Set states) { - if (states.contains(WidgetState.selected)) { - // TODO(damian-molinski): Those colors are not using properties. - // TODO(damian-molinski): Dark/Transparent/On primary surface P40 016 - // TODO(damian-molinski): Light/Transparent/On surface P40 08 - return switch (_brightness) { - Brightness.dark => const Color(0x29123cd3), - Brightness.light => const Color(0x1f123cd3), - }; - } - - return null; - } -} - -class _ForegroundColor extends WidgetStateProperty { - final VoicesColorScheme _colors; - - _ForegroundColor(this._colors); - - @override - Color? resolve(Set states) { - if (states.contains(WidgetState.disabled)) { - return _colors.textDisabled; - } - - return _colors.textOnPrimaryLevel1; - } -} - -class _LabelTextStyle extends WidgetStateProperty { - final TextTheme _textTheme; - - _LabelTextStyle(this._textTheme); - - @override - TextStyle resolve(Set states) { - if (states.contains(WidgetState.selected)) { - return _textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.bold); - } - - return _textTheme.bodyLarge!; - } -} - -class _BorderColor extends WidgetStateProperty { - final Brightness _brightness; - - _BorderColor(this._brightness); - - @override - BoxBorder resolve(Set states) { - // TODO(damian-molinski): Those colors are not using properties. - // TODO(damian-molinski): Elevations/On surface/Neutral/Transparent/on surface N10 08 - // TODO(damian-molinski): Elevations/On surface/Neutral/Transparent/on surface N10 08 - return switch (_brightness) { - Brightness.dark => Border.all(color: const Color(0x1fbfc8d9)), - Brightness.light => Border.all(color: const Color(0x14212a3d)), - }; - } -} diff --git a/catalyst_voices/apps/voices/lib/widgets/modals/campaign/campaign_categories_tile.dart b/catalyst_voices/apps/voices/lib/widgets/modals/campaign/campaign_categories_tile.dart deleted file mode 100644 index 644ffcb9438..00000000000 --- a/catalyst_voices/apps/voices/lib/widgets/modals/campaign/campaign_categories_tile.dart +++ /dev/null @@ -1,165 +0,0 @@ -import 'package:catalyst_voices/widgets/menu/voices_modal_menu.dart'; -import 'package:catalyst_voices/widgets/tiles/voices_expansion_tile.dart'; -import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; -import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; -import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; -import 'package:collection/collection.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - -class CampaignCategoriesTile extends StatefulWidget { - final List sections; - - const CampaignCategoriesTile({ - super.key, - required this.sections, - }); - - @override - State createState() => _CampaignCategoriesTileState(); -} - -class _CampaignCategoriesTileState extends State { - String? _selectedSectionId; - - @override - void initState() { - super.initState(); - - _selectedSectionId = widget.sections.firstOrNull?.id; - } - - @override - void didUpdateWidget(covariant CampaignCategoriesTile oldWidget) { - super.didUpdateWidget(oldWidget); - - if (!listEquals(widget.sections, oldWidget.sections)) { - if (!widget.sections.any((element) => element.id == _selectedSectionId)) { - _selectedSectionId = widget.sections.firstOrNull?.id; - } - } - } - - @override - Widget build(BuildContext context) { - final selectedSection = widget.sections - .singleWhereOrNull((element) => element.id == _selectedSectionId); - - return VoicesExpansionTile( - initiallyExpanded: true, - title: Text(context.l10n.campaignCategories), - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _Menu( - selectedId: _selectedSectionId, - menuItems: widget.sections, - onTap: _updateSelection, - ), - const SizedBox(width: 32), - Expanded( - child: selectedSection != null - ? _Details(section: selectedSection) - : const SizedBox(), - ), - ], - ), - ], - ); - } - - void _updateSelection(String id) { - setState(() { - _selectedSectionId = id; - }); - } -} - -class _Menu extends StatelessWidget { - final String? selectedId; - final List menuItems; - final ValueChanged onTap; - - const _Menu({ - this.selectedId, - required this.menuItems, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final textTheme = theme.textTheme; - final colors = theme.colors; - - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 16), - Text( - context.l10n.cardanoUseCases, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: textTheme.titleSmall?.copyWith( - color: colors.textOnPrimaryLevel0, - ), - ), - const SizedBox(height: 12), - VoicesModalMenu( - selectedId: selectedId, - menuItems: menuItems, - onTap: onTap, - ), - const SizedBox(height: 16), - ], - ); - } -} - -class _Details extends StatelessWidget { - final CampaignSection section; - - const _Details({ - required this.section, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final textTheme = theme.textTheme; - final colors = theme.colors; - - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 48), - Text( - section.label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: textTheme.headlineMedium?.copyWith( - color: colors.textOnPrimaryLevel1, - ), - ), - const SizedBox(height: 24), - Text( - section.title, - style: textTheme.titleLarge?.copyWith( - color: colors.textOnPrimaryLevel0, - ), - ), - const SizedBox(height: 16), - Text( - section.body, - style: textTheme.bodyLarge?.copyWith( - color: colors.textOnPrimaryLevel1, - ), - ), - const SizedBox(height: 32), - ], - ); - } -} diff --git a/catalyst_voices/apps/voices/lib/widgets/modals/campaign/campaign_details_tile.dart b/catalyst_voices/apps/voices/lib/widgets/modals/campaign/campaign_details_tile.dart deleted file mode 100644 index f07a02a6e4e..00000000000 --- a/catalyst_voices/apps/voices/lib/widgets/modals/campaign/campaign_details_tile.dart +++ /dev/null @@ -1,219 +0,0 @@ -import 'package:catalyst_voices/common/formatters/date_formatter.dart'; -import 'package:catalyst_voices/widgets/tiles/voices_expansion_tile.dart'; -import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; -import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; -import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; -import 'package:flutter/material.dart'; - -class CampaignDetailsTile extends StatelessWidget { - final String description; - final DateTime publishDate; - final DateTime startDate; - final DateTime endDate; - final int categoriesCount; - final int proposalsCount; - - const CampaignDetailsTile({ - super.key, - required this.description, - required this.publishDate, - required this.startDate, - required this.endDate, - required this.categoriesCount, - required this.proposalsCount, - }); - - @override - Widget build(BuildContext context) { - return VoicesExpansionTile( - initiallyExpanded: true, - title: Text(context.l10n.campaignDetails), - children: [ - _Body( - description: description, - ), - const SizedBox(height: 16 + 24), - _CampaignData( - publishDate: publishDate, - startDate: startDate, - endDate: endDate, - categoriesCount: categoriesCount, - proposalsCount: proposalsCount, - ), - ], - ); - } -} - -class _Body extends StatelessWidget { - final String description; - - const _Body({ - required this.description, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final textTheme = theme.textTheme; - final colors = theme.colors; - - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - context.l10n.description, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: textTheme.titleSmall?.copyWith( - color: colors.textOnPrimaryLevel1, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 14), - Text( - description, - style: textTheme.bodyLarge?.copyWith( - color: colors.textOnPrimaryLevel1, - ), - ), - ], - ); - } -} - -class _CampaignData extends StatelessWidget { - final DateTime publishDate; - final DateTime startDate; - final DateTime endDate; - final int categoriesCount; - final int proposalsCount; - - const _CampaignData({ - required this.publishDate, - required this.startDate, - required this.endDate, - required this.categoriesCount, - required this.proposalsCount, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final colors = theme.colors; - final l10n = context.l10n; - - return Container( - decoration: BoxDecoration( - color: colors.onSurfacePrimary012, - borderRadius: BorderRadius.circular(12), - ), - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), - child: Row( - children: [ - _CampaignDataTile( - key: const ValueKey('StartDateTileKey'), - title: l10n.startDate, - subtitle: DateFormatter.formatInDays(l10n, startDate), - value: startDate.day, - valueSuffix: DateFormatter.formatShortMonth(l10n, startDate), - ), - _CampaignDataTile( - key: const ValueKey('EndDateTileKey'), - title: l10n.endDate, - subtitle: DateFormatter.formatInDays(l10n, endDate), - value: endDate.day, - valueSuffix: DateFormatter.formatShortMonth(l10n, endDate), - ), - _CampaignDataTile( - key: const ValueKey('CategoriesTileKey'), - title: l10n.categories, - subtitle: DateFormatter.formatInDays( - l10n, - DateTime.now(), - from: publishDate, - ), - value: categoriesCount, - ), - _CampaignDataTile( - key: const ValueKey('ProposalsTileKey'), - title: l10n.proposals, - subtitle: l10n.totalSubmitted, - value: proposalsCount, - ), - ] - .map((e) => Expanded(child: e)) - .separatedBy(const SizedBox(width: 16)) - .toList(), - ), - ); - } -} - -class _CampaignDataTile extends StatelessWidget { - final String title; - final String subtitle; - final int value; - final String? valueSuffix; - - const _CampaignDataTile({ - super.key, - required this.title, - required this.subtitle, - required this.value, - this.valueSuffix, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final textTheme = theme.textTheme; - final colors = theme.colors; - - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - title, - style: textTheme.titleSmall?.copyWith( - color: colors.textOnPrimaryLevel1, - ), - ), - Text( - subtitle, - style: textTheme.bodySmall?.copyWith( - // TODO(damian-molinski): This color does not have property. - // Colors/sys color neutral md ref/N60 - color: const Color(0xFF7F90B3), - ), - ), - const SizedBox(height: 16), - Row( - textBaseline: TextBaseline.alphabetic, - crossAxisAlignment: valueSuffix != null - ? CrossAxisAlignment.baseline - : CrossAxisAlignment.end, - children: [ - Text( - '$value', - style: textTheme.headlineLarge?.copyWith( - color: colors.textOnPrimaryLevel1, - ), - ), - if (valueSuffix != null) ...[ - const SizedBox(width: 4), - Text( - valueSuffix!, - style: textTheme.titleMedium?.copyWith( - color: colors.textOnPrimaryLevel1, - ), - ), - ], - ], - ), - ], - ); - } -} diff --git a/catalyst_voices/apps/voices/lib/widgets/text_field/voices_password_text_field.dart b/catalyst_voices/apps/voices/lib/widgets/text_field/voices_password_text_field.dart index 190bfaa79b4..e7c20a7810b 100644 --- a/catalyst_voices/apps/voices/lib/widgets/text_field/voices_password_text_field.dart +++ b/catalyst_voices/apps/voices/lib/widgets/text_field/voices_password_text_field.dart @@ -19,9 +19,6 @@ final class VoicesPasswordTextField extends StatelessWidget { /// Optional decoration. See [VoicesTextField] for more details. final VoicesTextFieldDecoration? decoration; - /// [VoicesTextField.autofocus]. - final bool autofocus; - const VoicesPasswordTextField({ super.key, this.controller, @@ -29,7 +26,6 @@ final class VoicesPasswordTextField extends StatelessWidget { this.onChanged, this.onSubmitted, this.decoration, - this.autofocus = false, }); @override @@ -37,7 +33,6 @@ final class VoicesPasswordTextField extends StatelessWidget { return VoicesTextField( controller: controller, keyboardType: TextInputType.visiblePassword, - autofocus: autofocus, obscureText: true, textInputAction: textInputAction, onChanged: onChanged, diff --git a/catalyst_voices/apps/voices/lib/widgets/text_field/voices_text_field.dart b/catalyst_voices/apps/voices/lib/widgets/text_field/voices_text_field.dart index e63a838bf80..31220324918 100644 --- a/catalyst_voices/apps/voices/lib/widgets/text_field/voices_text_field.dart +++ b/catalyst_voices/apps/voices/lib/widgets/text_field/voices_text_field.dart @@ -32,9 +32,6 @@ class VoicesTextField extends StatefulWidget { /// [TextField.style] final TextStyle? style; - /// [TextField.autofocus] - final bool autofocus; - /// [TextField.obscureText] final bool obscureText; @@ -80,7 +77,6 @@ class VoicesTextField extends StatefulWidget { this.textInputAction, this.textCapitalization = TextCapitalization.none, this.style, - this.autofocus = false, this.obscureText = false, this.maxLength, this.maxLines = 1, @@ -180,7 +176,6 @@ class _VoicesTextFieldState extends State { resizableVertically: resizable, child: TextFormField( textAlignVertical: TextAlignVertical.top, - autofocus: widget.autofocus, expands: resizable, controller: _obtainController(), focusNode: widget.focusNode, diff --git a/catalyst_voices/apps/voices/lib/widgets/tiles/voices_expansion_tile.dart b/catalyst_voices/apps/voices/lib/widgets/tiles/voices_expansion_tile.dart deleted file mode 100644 index 46aca16a6a8..00000000000 --- a/catalyst_voices/apps/voices/lib/widgets/tiles/voices_expansion_tile.dart +++ /dev/null @@ -1,107 +0,0 @@ -import 'package:catalyst_voices/widgets/buttons/voices_buttons.dart'; -import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; -import 'package:flutter/material.dart'; - -class VoicesExpansionTile extends StatefulWidget { - final Widget title; - final List children; - final bool initiallyExpanded; - - const VoicesExpansionTile({ - super.key, - required this.title, - this.children = const [], - this.initiallyExpanded = false, - }); - - @override - State createState() => _VoicesExpansionTileState(); -} - -class _VoicesExpansionTileState extends State { - final _controller = ExpansionTileController(); - - bool _isExpanded = false; - - @override - void initState() { - super.initState(); - _isExpanded = widget.initiallyExpanded; - } - - @override - Widget build(BuildContext context) { - return _ThemeOverride( - child: Builder( - builder: (context) { - final theme = Theme.of(context); - - return ExpansionTile( - title: DefaultTextStyle( - style: theme.textTheme.titleLarge ?? const TextStyle(), - maxLines: 1, - overflow: TextOverflow.ellipsis, - child: widget.title, - ), - trailing: ChevronExpandButton( - isExpanded: _isExpanded, - onTap: _toggleExpand, - ), - controller: _controller, - initiallyExpanded: _isExpanded, - onExpansionChanged: _updateExpended, - children: widget.children, - ); - }, - ), - ); - } - - void _updateExpended(bool value) { - setState(() { - _isExpanded = value; - }); - } - - void _toggleExpand() { - if (_controller.isExpanded) { - _controller.collapse(); - } else { - _controller.expand(); - } - } -} - -class _ThemeOverride extends StatelessWidget { - final Widget child; - - const _ThemeOverride({ - required this.child, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Theme( - data: theme.copyWith( - // listTileTheme is required here because ExpansionTile does not let - // us set shape or ripple used internally by ListTile. - listTileTheme: const ListTileThemeData(shape: RoundedRectangleBorder()), - expansionTileTheme: ExpansionTileThemeData( - backgroundColor: theme.colors.elevationsOnSurfaceNeutralLv0, - collapsedBackgroundColor: theme.colors.elevationsOnSurfaceNeutralLv0, - tilePadding: const EdgeInsets.fromLTRB(24, 8, 12, 8), - childrenPadding: const EdgeInsets.fromLTRB(24, 16, 24, 24), - textColor: theme.colors.textOnPrimaryLevel1, - collapsedTextColor: theme.colors.textOnPrimaryLevel1, - iconColor: theme.colors.iconsForeground, - collapsedIconColor: theme.colors.iconsForeground, - shape: const RoundedRectangleBorder(), - collapsedShape: const RoundedRectangleBorder(), - ), - ), - child: child, - ); - } -} diff --git a/catalyst_voices/apps/voices/lib/widgets/widgets.dart b/catalyst_voices/apps/voices/lib/widgets/widgets.dart index a43e5437a5a..d3ad43f3ba5 100644 --- a/catalyst_voices/apps/voices/lib/widgets/widgets.dart +++ b/catalyst_voices/apps/voices/lib/widgets/widgets.dart @@ -46,7 +46,6 @@ export 'indicators/voices_status_indicator.dart'; export 'list/bullet_list.dart'; export 'menu/voices_list_tile.dart'; export 'menu/voices_menu.dart'; -export 'menu/voices_modal_menu.dart'; export 'menu/voices_node_menu.dart'; export 'menu/voices_wallet_tile.dart'; export 'modals/voices_alert_dialog.dart'; @@ -72,7 +71,6 @@ export 'text_field/voices_autocomplete.dart'; export 'text_field/voices_email_text_field.dart'; export 'text_field/voices_password_text_field.dart'; export 'text_field/voices_text_field.dart'; -export 'tiles/voices_expansion_tile.dart'; export 'tiles/voices_nav_tile.dart'; export 'toggles/voices_checkbox.dart'; export 'toggles/voices_checkbox_group.dart'; diff --git a/catalyst_voices/apps/voices/test/common/formatters/date_formatter_test.dart b/catalyst_voices/apps/voices/test/common/formatters/date_formatter_test.dart index a07b0dee89f..0b163672d45 100644 --- a/catalyst_voices/apps/voices/test/common/formatters/date_formatter_test.dart +++ b/catalyst_voices/apps/voices/test/common/formatters/date_formatter_test.dart @@ -1,94 +1,53 @@ import 'package:catalyst_voices/common/formatters/date_formatter.dart'; -import 'package:catalyst_voices_localization/generated/catalyst_voices_localizations_en.dart'; +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:intl/intl.dart'; +class _FakeVoicesLocalizations extends Fake implements VoicesLocalizations { + @override + String get today => 'Today'; + @override + String get tomorrow => 'Tomorrow'; + @override + String get yesterday => 'Yesterday'; + @override + String get twoDaysAgo => '2 days ago'; +} + void main() { group(DateFormatter, () { - final l10n = VoicesLocalizationsEn(); - - group('formatRecentDate', () { - test('should return "Today" for today\'s date', () { - final today = DateTimeExt.now(); - final result = DateFormatter.formatRecentDate(l10n, today); - expect(result, l10n.today); - }); - - test('should return "Tomorrow" for tomorrow\'s date', () { - final tomorrow = DateTimeExt.now().plusDays(1); - final result = DateFormatter.formatRecentDate(l10n, tomorrow); - expect(result, l10n.tomorrow); - }); + final l10n = _FakeVoicesLocalizations(); - test('should return "Yesterday" for yesterday\'s date', () { - final yesterday = DateTimeExt.now().minusDays(1); - final result = DateFormatter.formatRecentDate(l10n, yesterday); - expect(result, l10n.yesterday); - }); - - test('should return "2 days ago" for a date 2 days ago', () { - final twoDaysAgo = DateTimeExt.now().minusDays(2); - final result = DateFormatter.formatRecentDate(l10n, twoDaysAgo); - expect(result, l10n.twoDaysAgo); - }); - - test('should return formatted date for older dates', () { - final pastDate = DateTimeExt.now().minusDays(10); - final result = DateFormatter.formatRecentDate(l10n, pastDate); - final expectedFormat = DateFormat.yMMMMd().format(pastDate); - expect(result, expectedFormat); - }); + test('should return "Today" for today\'s date', () { + final today = DateTimeExt.now(); + final result = DateFormatter.formatRecentDate(l10n, today); + expect(result, l10n.today); }); - group('formatInDays', () { - test('returns 20 days when in comparing to 20 days in future', () { - // Given - final publishDate = DateTime(2024, 11, 20); - final now = DateTime(2024, 11, 0); - - // When - final result = DateFormatter.formatInDays( - l10n, - publishDate, - from: now, - ); - - // Then - expect(result, 'In 20 days'); - }); - - test('returns 0 days when in comparing to past', () { - // Given - final publishDate = DateTime(2024, 2, 10); - final now = DateTime(2024, 11, 0); - - // When - final result = DateFormatter.formatInDays( - l10n, - publishDate, - from: now, - ); - - // Then - expect(result, 'In 0 days'); - }); + test('should return "Tomorrow" for tomorrow\'s date', () { + final tomorrow = DateTimeExt.now().plusDays(1); + final result = DateFormatter.formatRecentDate(l10n, tomorrow); + expect(result, l10n.tomorrow); + }); - test('returns 1 day when in comparing to 1 day in future', () { - // Given - final publishDate = DateTime(2024, 11, 1); - final now = DateTime(2024, 11, 0); + test('should return "Yesterday" for yesterday\'s date', () { + final yesterday = DateTimeExt.now().minusDays(1); + final result = DateFormatter.formatRecentDate(l10n, yesterday); + expect(result, l10n.yesterday); + }); - // When - final result = DateFormatter.formatInDays( - l10n, - publishDate, - from: now, - ); + test('should return "2 days ago" for a date 2 days ago', () { + final twoDaysAgo = DateTimeExt.now().minusDays(2); + final result = DateFormatter.formatRecentDate(l10n, twoDaysAgo); + expect(result, l10n.twoDaysAgo); + }); - // Then - expect(result, 'In 1 day'); - }); + test('should return formatted date for older dates', () { + final pastDate = DateTimeExt.now().minusDays(10); + final result = DateFormatter.formatRecentDate(l10n, pastDate); + final expectedFormat = DateFormat.yMMMMd().format(pastDate); + expect(result, expectedFormat); }); }); } diff --git a/catalyst_voices/apps/voices/test/widgets/empty_state/empty_state_test.dart b/catalyst_voices/apps/voices/test/widgets/empty_state/empty_state_test.dart deleted file mode 100644 index d19e3df0b8b..00000000000 --- a/catalyst_voices/apps/voices/test/widgets/empty_state/empty_state_test.dart +++ /dev/null @@ -1,131 +0,0 @@ -import 'package:catalyst_voices/widgets/empty_state/empty_state.dart'; -import 'package:catalyst_voices/widgets/images/voices_image_scheme.dart'; -import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; -import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../../helpers/helpers.dart'; - -void main() { - group('EmptyState Widget Tests', () { - testWidgets('Renders correctly with default values', (tester) async { - await tester.pumpApp( - const EmptyState(), - ); - await tester.pumpAndSettle(); - - expect(find.byType(CatalystSvgPicture), findsOneWidget); - expect(find.byType(Text), findsNWidgets(2)); - expect(find.text('No draft proposals yet'), findsOneWidget); - expect( - find.text( - // ignore: lines_longer_than_80_chars - 'Discovery space will show draft proposals you can comment on, currently there are no draft proposals.', - ), - findsOneWidget, - ); - }); - - testWidgets('Renders correctly with custom values', (tester) async { - await tester.pumpApp( - const EmptyState( - title: 'Custom Title', - description: 'Custom Description', - ), - ); - await tester.pumpAndSettle(); - - expect(find.byType(CatalystSvgPicture), findsOneWidget); - expect(find.text('Custom Title'), findsOneWidget); - expect(find.text('Custom Description'), findsOneWidget); - }); - - testWidgets('Uses correct custom color scheme', (tester) async { - const colors = - VoicesColorScheme.optional(textOnPrimaryLevel1: Colors.red); - await tester.pumpApp( - voicesColors: colors, - const EmptyState( - title: 'Custom Title', - description: 'Custom Description', - ), - ); - await tester.pumpAndSettle(); - - final titleText = tester.widget( - find.byType(Text).first, - ); - - expect( - titleText.style?.color, - colors.textOnPrimaryLevel1, - ); - - final descriptionText = tester.widget( - find.byType(Text).last, - ); - - expect( - descriptionText.style?.color, - colors.textOnPrimaryLevel1, - ); - }); - - testWidgets( - 'Proposal image changes depending on theme brightness', - (tester) async { - // Given - const widget = EmptyState(); - - // When - Light theme - await tester.pumpApp( - widget, - theme: ThemeData(brightness: Brightness.light), - voicesColors: const VoicesColorScheme.optional(), - ); - await tester.pumpAndSettle(); - - // Then - Light theme - final lightThemeImage = tester.widget( - find.byType(CatalystSvgPicture), - ); - expect( - lightThemeImage, - isA(), - ); - - // When - Dark theme - await tester.pumpApp( - widget, - theme: ThemeData(brightness: Brightness.dark), - voicesColors: const VoicesColorScheme.optional(), - ); - await tester.pumpAndSettle(); - - // Then - Dark theme - final darkThemeImage = tester.widget( - find.byType(CatalystSvgPicture), - ); - expect( - darkThemeImage, - isA(), - ); - }, - ); - - testWidgets('Renders correctly with custom image', (tester) async { - await tester.pumpApp( - EmptyState( - image: CatalystSvgPicture.asset( - VoicesAssets.images.noProposalForeground.path, - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.byType(CatalystSvgPicture), findsOneWidget); - expect(find.byType(VoicesImagesScheme), findsNothing); - }); - }); -} diff --git a/catalyst_voices/apps/voices/test/widgets/menu/voices_menu_test.dart b/catalyst_voices/apps/voices/test/widgets/menu/voices_menu_test.dart index 2e09d8fb64a..4ceb87006f3 100644 --- a/catalyst_voices/apps/voices/test/widgets/menu/voices_menu_test.dart +++ b/catalyst_voices/apps/voices/test/widgets/menu/voices_menu_test.dart @@ -9,34 +9,34 @@ import '../../helpers/helpers.dart'; void main() { final menu = [ MenuItem( - id: '1', + id: 1, label: 'Rename', icon: VoicesAssets.icons.pencil.buildIcon(), ), SubMenuItem( - id: '2', + id: 2, label: 'Move Private Team', icon: VoicesAssets.icons.switchHorizontal.buildIcon(), - children: const [ + children: [ MenuItem( - id: '3', + id: 3, label: 'Team 1: The Vikings', ), MenuItem( - id: '4', + id: 4, label: 'Team 2: Pure Hearts', ), ], ), MenuItem( - id: '5', + id: 5, label: 'Move to public', icon: VoicesAssets.icons.switchHorizontal.buildIcon(), showDivider: true, - isEnabled: false, + enabled: false, ), MenuItem( - id: '6', + id: 6, label: 'Delete', icon: VoicesAssets.icons.trash.buildIcon(), ), diff --git a/catalyst_voices/packages/internal/catalyst_voices_assets/assets/images/no_proposal_foreground.svg b/catalyst_voices/packages/internal/catalyst_voices_assets/assets/images/no_proposal_foreground.svg deleted file mode 100644 index df9c9749f62..00000000000 --- a/catalyst_voices/packages/internal/catalyst_voices_assets/assets/images/no_proposal_foreground.svg +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/catalyst_voices/packages/internal/catalyst_voices_localization/lib/l10n/intl_en.arb b/catalyst_voices/packages/internal/catalyst_voices_localization/lib/l10n/intl_en.arb index f50bd18df7b..de5f9c1f9a1 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_localization/lib/l10n/intl_en.arb +++ b/catalyst_voices/packages/internal/catalyst_voices_localization/lib/l10n/intl_en.arb @@ -986,30 +986,5 @@ "noGuidanceForThisSection": "There is no guidance for this section", "@noGuidanceForThisSection": { "description": "Message when there is no guidance for this section" - }, - "noProposalStateDescription": "Discovery space will show draft proposals you can comment on, currently there are no draft proposals.", - "@noProposalStateDescription": { - "description": "Description shown when there are no proposals in the proposals tab" - }, - "noProposalStateTitle": "No draft proposals yet", - "@noProposalStateTitle": { - "description": "Title shown when there are no proposals in the proposals tab" - }, - "campaignDetails": "Campaign Details", - "description": "Description", - "startDate": "Start Date", - "endDate": "End Date", - "categories": "Categories", - "proposals": "Proposals", - "totalSubmitted": "Total submitted", - "inXDays": "{x, plural, =1{In {x} day} other{In {x} days}}", - "@inXDays": { - "placeholders": { - "x": { - "type": "int" - } - } - }, - "campaignCategories": "Campaign Categories", - "cardanoUseCases": "Cardano Use Cases" + } } \ No newline at end of file diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/keychain/vault_keychain.dart b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/keychain/vault_keychain.dart index 171fb17495f..9a6ad8e7559 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/keychain/vault_keychain.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/keychain/vault_keychain.dart @@ -127,4 +127,7 @@ final class VaultKeychain extends SecureStorageVault implements Keychain { @override String toString() => 'VaultKeychain[$id]'; + + @override + List get props => [id]; } diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/storage/vault/secure_storage_vault.dart b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/storage/vault/secure_storage_vault.dart index 7bef14095d4..00e91b49872 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/storage/vault/secure_storage_vault.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/storage/vault/secure_storage_vault.dart @@ -6,6 +6,7 @@ import 'package:catalyst_voices_services/src/crypto/crypto_service.dart'; import 'package:catalyst_voices_services/src/crypto/vault_crypto_service.dart'; import 'package:catalyst_voices_services/src/storage/storage_string_mixin.dart'; import 'package:catalyst_voices_services/src/storage/vault/vault.dart'; +import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; @@ -13,8 +14,9 @@ const _lockKey = 'LockKey'; /// Implementation of [Vault] that uses [FlutterSecureStorage] as /// facade for read/write operations. -base class SecureStorageVault with StorageAsStringMixin implements Vault { - @override +base class SecureStorageVault + with StorageAsStringMixin, EquatableMixin + implements Vault { final String id; @protected final FlutterSecureStorage secureStorage; @@ -169,11 +171,6 @@ base class SecureStorageVault with StorageAsStringMixin implements Vault { } } - @override - String toString() { - return 'SecureStorageVault{id: $id}'; - } - /// Allows operation only when [isUnlocked] it true, otherwise returns null. /// /// Returns value assigned to [key]. May return null if not found for [key]. @@ -249,4 +246,7 @@ base class SecureStorageVault with StorageAsStringMixin implements Vault { void _erase(Uint8List list) { list.fillRange(0, list.length, 0); } + + @override + List get props => [id]; } diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/storage/vault/vault.dart b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/storage/vault/vault.dart index 67bbbb620d1..8d03c3ee084 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/storage/vault/vault.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/storage/vault/vault.dart @@ -7,6 +7,4 @@ import 'package:catalyst_voices_services/src/storage/storage.dart'; /// /// In order to unlock [Vault] sufficient [LockFactor] have to be /// set via [unlock] that can unlock [LockFactor] from [setLock]. -abstract interface class Vault implements Storage, Lockable { - String get id; -} +abstract interface class Vault implements Storage, Lockable {} diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/test/src/keychain/vault_keychain_provider_test.dart b/catalyst_voices/packages/internal/catalyst_voices_services/test/src/keychain/vault_keychain_provider_test.dart index f8418f71e06..8d66f091238 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/test/src/keychain/vault_keychain_provider_test.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/test/src/keychain/vault_keychain_provider_test.dart @@ -24,10 +24,7 @@ void main() { // Then expect(await provider.exists(id), isTrue); - expect( - [keychain.id], - await provider.getAll().then((value) => value.map((e) => e.id)), - ); + expect([keychain], await provider.getAll()); }); test('calling create twice on keychain will empty previous data', () async { diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/test/src/keychain/vault_keychain_test.dart b/catalyst_voices/packages/internal/catalyst_voices_services/test/src/keychain/vault_keychain_test.dart index 6d4d4d542a9..ccfd397b5a9 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/test/src/keychain/vault_keychain_test.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/test/src/keychain/vault_keychain_test.dart @@ -68,7 +68,7 @@ void main() { ); }); - test('are not equal when id is matching', () async { + test('are equal when id is matching', () async { // Given final id = const Uuid().v4(); @@ -77,7 +77,7 @@ void main() { final vaultTwo = VaultKeychain(id: id); // Then - expect(vaultOne, isNot(equals(vaultTwo))); + expect(vaultOne, equals(vaultTwo)); }); test('metadata dates are in UTC', () async { diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/test/src/user/user_service_test.dart b/catalyst_voices/packages/internal/catalyst_voices_services/test/src/user/user_service_test.dart index c4aaecb3b41..045abedef1e 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/test/src/user/user_service_test.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/test/src/user/user_service_test.dart @@ -29,7 +29,7 @@ void main() { // Then final currentKeychain = service.keychain; - expect(currentKeychain?.id, keychain.id); + expect(currentKeychain, keychain); }); test('using different keychain emits update in stream', () async { @@ -48,8 +48,8 @@ void main() { keychainStream, emitsInOrder([ isNull, - predicate((e) => e.id == keychainOne.id), - predicate((e) => e.id == keychainTwo.id), + keychainOne, + keychainTwo, isNull, ]), ); @@ -75,7 +75,7 @@ void main() { // Then final serviceKeychains = await service.keychains; - expect(serviceKeychains.map((e) => e.id), keychains.map((e) => e.id)); + expect(serviceKeychains, keychains); }); }); @@ -92,7 +92,7 @@ void main() { await service.useLastAccount(); // Then - expect(service.keychain?.id, expectedKeychain.id); + expect(service.keychain, expectedKeychain); }); test('use last account does nothing on clear instance', () async { diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_category.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_category.dart deleted file mode 100644 index fd2ca82d607..00000000000 --- a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_category.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'package:equatable/equatable.dart'; - -final class CampaignCategory extends Equatable { - final String id; - final String name; - - const CampaignCategory({ - required this.id, - required this.name, - }); - - @override - List get props => [id, name]; -} diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_section.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_section.dart deleted file mode 100644 index 0e8a346e34f..00000000000 --- a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/campaign/campaign_section.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; -import 'package:equatable/equatable.dart'; - -final class CampaignSection extends Equatable implements MenuItem { - @override - final String id; - final CampaignCategory category; - final String title; - final String body; - - const CampaignSection({ - required this.id, - required this.category, - required this.title, - required this.body, - }); - - @override - String get label => category.name; - - @override - bool get isEnabled => true; - - @override - List get props => [ - id, - category, - title, - body, - ]; -} diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/catalyst_voices_view_models.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/catalyst_voices_view_models.dart index a51dc69220c..f745b4c9141 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/catalyst_voices_view_models.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/catalyst_voices_view_models.dart @@ -1,10 +1,6 @@ export 'authentication/authentication.dart'; -export 'campaign/campaign_category.dart'; -export 'campaign/campaign_section.dart'; export 'exception/localized_exception.dart'; export 'exception/localized_unknown_exception.dart'; -export 'menu/menu_item.dart'; -export 'menu/popup_menu_item.dart'; export 'navigation/sections_list_view_item.dart'; export 'navigation/sections_navigation.dart'; export 'proposal/comment.dart'; diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/menu/menu_item.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/menu/menu_item.dart deleted file mode 100644 index 1481dba245e..00000000000 --- a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/menu/menu_item.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:equatable/equatable.dart'; - -abstract interface class MenuItem { - String get id; - - String get label; - - bool get isEnabled; -} - -base class BasicMenuItem extends Equatable implements MenuItem { - @override - final String id; - @override - final String label; - @override - final bool isEnabled; - - const BasicMenuItem({ - required this.id, - required this.label, - this.isEnabled = true, - }); - - @override - List get props => [ - id, - label, - isEnabled, - ]; -} diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/menu/popup_menu_item.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/menu/popup_menu_item.dart deleted file mode 100644 index 9299e44e0f5..00000000000 --- a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/menu/popup_menu_item.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; -import 'package:flutter/widgets.dart'; - -abstract interface class PopupMenuItem implements MenuItem { - Widget? get icon; - - bool get showDivider; -} - -base class BasicPopupMenuItem extends BasicMenuItem implements PopupMenuItem { - @override - final Widget? icon; - - @override - final bool showDivider; - - const BasicPopupMenuItem({ - required super.id, - required super.label, - super.isEnabled, - this.icon, - this.showDivider = false, - }); - - @override - List get props => - super.props + - [ - icon, - showDivider, - ]; -} diff --git a/catalyst_voices/utilities/uikit_example/lib/examples/voices_menu_example.dart b/catalyst_voices/utilities/uikit_example/lib/examples/voices_menu_example.dart index b85aa136885..3dcfa8fb0fa 100644 --- a/catalyst_voices/utilities/uikit_example/lib/examples/voices_menu_example.dart +++ b/catalyst_voices/utilities/uikit_example/lib/examples/voices_menu_example.dart @@ -71,28 +71,28 @@ class _MenuExample1 extends StatelessWidget { onTap: (menuItem) => debugPrint('Selected label: ${menuItem.label}'), menuItems: [ MenuItem( - id: '1', + id: 1, label: 'Rename', icon: VoicesAssets.icons.pencil.buildIcon(), ), SubMenuItem( - id: '4', + id: 4, label: 'Move Private Team', icon: VoicesAssets.icons.switchHorizontal.buildIcon(), - children: const [ - MenuItem(id: '5', label: 'Team 1: The Vikings'), - MenuItem(id: '6', label: 'Team 2: Pure Hearts'), + children: [ + MenuItem(id: 5, label: 'Team 1: The Vikings'), + MenuItem(id: 6, label: 'Team 2: Pure Hearts'), ], ), MenuItem( - id: '2', + id: 2, label: 'Move to public', icon: VoicesAssets.icons.switchHorizontal.buildIcon(), showDivider: true, - isEnabled: false, + enabled: false, ), MenuItem( - id: '3', + id: 3, label: 'Delete', icon: VoicesAssets.icons.trash.buildIcon(), ), @@ -118,27 +118,27 @@ class _MenuExample2 extends StatelessWidget { onTap: (menuItem) => debugPrint('Selected label: ${menuItem.label}'), menuItems: [ MenuItem( - id: '1', + id: 1, label: 'Rename', icon: VoicesAssets.icons.pencil.buildIcon(), ), SubMenuItem( - id: '4', + id: 4, label: 'Move Private Team', icon: VoicesAssets.icons.switchHorizontal.buildIcon(), - children: const [ - MenuItem(id: '5', label: 'Team 1: The Vikings'), - MenuItem(id: '6', label: 'Team 2: Pure Hearts'), + children: [ + MenuItem(id: 5, label: 'Team 1: The Vikings'), + MenuItem(id: 6, label: 'Team 2: Pure Hearts'), ], ), MenuItem( - id: '2', + id: 2, label: 'Move to public', icon: VoicesAssets.icons.switchHorizontal.buildIcon(), showDivider: true, ), MenuItem( - id: '3', + id: 3, label: 'Delete', icon: VoicesAssets.icons.trash.buildIcon(), ), From e551c61702ab4a229c88119a43611a42516b2665 Mon Sep 17 00:00:00 2001 From: Dominik Toton <166132265+dtscalac@users.noreply.github.com> Date: Wed, 27 Nov 2024 10:57:13 +0100 Subject: [PATCH 09/17] fix(cat-voices): equatable lint issue fix (#1280) * fix: resolve equatable lint issue * fix: missing override --- catalyst_voices/apps/voices/pubspec.yaml | 2 +- catalyst_voices/melos.yaml | 2 +- .../internal/catalyst_voices_blocs/pubspec.yaml | 2 +- .../internal/catalyst_voices_models/pubspec.yaml | 2 +- .../lib/src/keychain/vault_keychain.dart | 3 --- .../src/storage/vault/secure_storage_vault.dart | 14 +++++++------- .../lib/src/storage/vault/vault.dart | 4 +++- .../internal/catalyst_voices_services/pubspec.yaml | 2 +- .../src/keychain/vault_keychain_provider_test.dart | 5 ++++- .../test/src/keychain/vault_keychain_test.dart | 4 ++-- .../test/src/user/user_service_test.dart | 10 +++++----- .../catalyst_voices_view_models/pubspec.yaml | 2 +- .../catalyst_cardano/example/pubspec.yaml | 2 +- .../pubspec.yaml | 2 +- .../catalyst_cardano_serialization/pubspec.yaml | 2 +- .../pubspec.yaml | 2 +- .../libs/catalyst_key_derivation/pubspec.yaml | 2 +- 17 files changed, 32 insertions(+), 30 deletions(-) diff --git a/catalyst_voices/apps/voices/pubspec.yaml b/catalyst_voices/apps/voices/pubspec.yaml index 1dd7683242f..27987e9e804 100644 --- a/catalyst_voices/apps/voices/pubspec.yaml +++ b/catalyst_voices/apps/voices/pubspec.yaml @@ -34,7 +34,7 @@ dependencies: path: ../../packages/internal/catalyst_voices_view_models collection: ^1.18.0 dotted_border: ^2.1.0 - equatable: ^2.0.5 + equatable: ^2.0.7 file_picker: ^8.0.7 flutter: sdk: flutter diff --git a/catalyst_voices/melos.yaml b/catalyst_voices/melos.yaml index ccc055e9265..14510cecc2d 100644 --- a/catalyst_voices/melos.yaml +++ b/catalyst_voices/melos.yaml @@ -92,7 +92,7 @@ command: cryptography: ^2.7.0 dotted_border: ^2.1.0 ed25519_hd_key: ^2.3.0 - equatable: ^2.0.5 + equatable: ^2.0.7 ffi: ^2.1.0 ffigen: ^11.0.0 file_picker: ^8.0.7 diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/pubspec.yaml b/catalyst_voices/packages/internal/catalyst_voices_blocs/pubspec.yaml index d8ba90d88d1..f420d231cf7 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/pubspec.yaml +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/pubspec.yaml @@ -26,7 +26,7 @@ dependencies: catalyst_voices_view_models: path: ../catalyst_voices_view_models collection: ^1.18.0 - equatable: ^2.0.5 + equatable: ^2.0.7 flutter: sdk: flutter flutter_bloc: ^8.1.5 diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/pubspec.yaml b/catalyst_voices/packages/internal/catalyst_voices_models/pubspec.yaml index 1cf08696dfb..e83c92fefdd 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/pubspec.yaml +++ b/catalyst_voices/packages/internal/catalyst_voices_models/pubspec.yaml @@ -13,7 +13,7 @@ dependencies: catalyst_cardano_web: ^0.3.0 collection: ^1.18.0 convert: ^3.1.1 - equatable: ^2.0.5 + equatable: ^2.0.7 meta: ^1.10.0 password_strength: ^0.2.0 diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/keychain/vault_keychain.dart b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/keychain/vault_keychain.dart index 9a6ad8e7559..171fb17495f 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/keychain/vault_keychain.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/keychain/vault_keychain.dart @@ -127,7 +127,4 @@ final class VaultKeychain extends SecureStorageVault implements Keychain { @override String toString() => 'VaultKeychain[$id]'; - - @override - List get props => [id]; } diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/storage/vault/secure_storage_vault.dart b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/storage/vault/secure_storage_vault.dart index 00e91b49872..7bef14095d4 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/storage/vault/secure_storage_vault.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/storage/vault/secure_storage_vault.dart @@ -6,7 +6,6 @@ import 'package:catalyst_voices_services/src/crypto/crypto_service.dart'; import 'package:catalyst_voices_services/src/crypto/vault_crypto_service.dart'; import 'package:catalyst_voices_services/src/storage/storage_string_mixin.dart'; import 'package:catalyst_voices_services/src/storage/vault/vault.dart'; -import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; @@ -14,9 +13,8 @@ const _lockKey = 'LockKey'; /// Implementation of [Vault] that uses [FlutterSecureStorage] as /// facade for read/write operations. -base class SecureStorageVault - with StorageAsStringMixin, EquatableMixin - implements Vault { +base class SecureStorageVault with StorageAsStringMixin implements Vault { + @override final String id; @protected final FlutterSecureStorage secureStorage; @@ -171,6 +169,11 @@ base class SecureStorageVault } } + @override + String toString() { + return 'SecureStorageVault{id: $id}'; + } + /// Allows operation only when [isUnlocked] it true, otherwise returns null. /// /// Returns value assigned to [key]. May return null if not found for [key]. @@ -246,7 +249,4 @@ base class SecureStorageVault void _erase(Uint8List list) { list.fillRange(0, list.length, 0); } - - @override - List get props => [id]; } diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/storage/vault/vault.dart b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/storage/vault/vault.dart index 8d03c3ee084..67bbbb620d1 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/storage/vault/vault.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/storage/vault/vault.dart @@ -7,4 +7,6 @@ import 'package:catalyst_voices_services/src/storage/storage.dart'; /// /// In order to unlock [Vault] sufficient [LockFactor] have to be /// set via [unlock] that can unlock [LockFactor] from [setLock]. -abstract interface class Vault implements Storage, Lockable {} +abstract interface class Vault implements Storage, Lockable { + String get id; +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/pubspec.yaml b/catalyst_voices/packages/internal/catalyst_voices_services/pubspec.yaml index cad7dc6701c..9d7f0d025ce 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/pubspec.yaml +++ b/catalyst_voices/packages/internal/catalyst_voices_services/pubspec.yaml @@ -20,7 +20,7 @@ dependencies: convert: ^3.1.1 cryptography: ^2.7.0 ed25519_hd_key: ^2.3.0 - equatable: ^2.0.5 + equatable: ^2.0.7 flutter: sdk: flutter flutter_secure_storage: ^9.2.2 diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/test/src/keychain/vault_keychain_provider_test.dart b/catalyst_voices/packages/internal/catalyst_voices_services/test/src/keychain/vault_keychain_provider_test.dart index 8d66f091238..f8418f71e06 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/test/src/keychain/vault_keychain_provider_test.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/test/src/keychain/vault_keychain_provider_test.dart @@ -24,7 +24,10 @@ void main() { // Then expect(await provider.exists(id), isTrue); - expect([keychain], await provider.getAll()); + expect( + [keychain.id], + await provider.getAll().then((value) => value.map((e) => e.id)), + ); }); test('calling create twice on keychain will empty previous data', () async { diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/test/src/keychain/vault_keychain_test.dart b/catalyst_voices/packages/internal/catalyst_voices_services/test/src/keychain/vault_keychain_test.dart index ccfd397b5a9..6d4d4d542a9 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/test/src/keychain/vault_keychain_test.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/test/src/keychain/vault_keychain_test.dart @@ -68,7 +68,7 @@ void main() { ); }); - test('are equal when id is matching', () async { + test('are not equal when id is matching', () async { // Given final id = const Uuid().v4(); @@ -77,7 +77,7 @@ void main() { final vaultTwo = VaultKeychain(id: id); // Then - expect(vaultOne, equals(vaultTwo)); + expect(vaultOne, isNot(equals(vaultTwo))); }); test('metadata dates are in UTC', () async { diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/test/src/user/user_service_test.dart b/catalyst_voices/packages/internal/catalyst_voices_services/test/src/user/user_service_test.dart index 045abedef1e..c4aaecb3b41 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/test/src/user/user_service_test.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/test/src/user/user_service_test.dart @@ -29,7 +29,7 @@ void main() { // Then final currentKeychain = service.keychain; - expect(currentKeychain, keychain); + expect(currentKeychain?.id, keychain.id); }); test('using different keychain emits update in stream', () async { @@ -48,8 +48,8 @@ void main() { keychainStream, emitsInOrder([ isNull, - keychainOne, - keychainTwo, + predicate((e) => e.id == keychainOne.id), + predicate((e) => e.id == keychainTwo.id), isNull, ]), ); @@ -75,7 +75,7 @@ void main() { // Then final serviceKeychains = await service.keychains; - expect(serviceKeychains, keychains); + expect(serviceKeychains.map((e) => e.id), keychains.map((e) => e.id)); }); }); @@ -92,7 +92,7 @@ void main() { await service.useLastAccount(); // Then - expect(service.keychain, expectedKeychain); + expect(service.keychain?.id, expectedKeychain.id); }); test('use last account does nothing on clear instance', () async { diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/pubspec.yaml b/catalyst_voices/packages/internal/catalyst_voices_view_models/pubspec.yaml index 84c2462ecc0..b87ce58469f 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_view_models/pubspec.yaml +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/pubspec.yaml @@ -17,7 +17,7 @@ dependencies: path: ../catalyst_voices_localization catalyst_voices_models: path: ../catalyst_voices_models - equatable: ^2.0.5 + equatable: ^2.0.7 flutter: sdk: flutter formz: ^0.7.0 diff --git a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/example/pubspec.yaml b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/example/pubspec.yaml index eaa42e72f1e..0a30d979fe4 100644 --- a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/example/pubspec.yaml +++ b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/example/pubspec.yaml @@ -18,7 +18,7 @@ dependencies: cbor: ^6.2.0 convert: ^3.1.1 cupertino_icons: ^1.0.6 - equatable: ^2.0.5 + equatable: ^2.0.7 flutter: sdk: flutter diff --git a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano_platform_interface/pubspec.yaml b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano_platform_interface/pubspec.yaml index 9f97f5edb55..d2c0f8fb23b 100644 --- a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano_platform_interface/pubspec.yaml +++ b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano_platform_interface/pubspec.yaml @@ -11,7 +11,7 @@ environment: dependencies: catalyst_cardano_serialization: ^0.4.0 - equatable: ^2.0.5 + equatable: ^2.0.7 flutter: sdk: flutter plugin_platform_interface: ^2.1.7 diff --git a/catalyst_voices/packages/libs/catalyst_cardano_serialization/pubspec.yaml b/catalyst_voices/packages/libs/catalyst_cardano_serialization/pubspec.yaml index 68cfd6fcc5e..5073d1d1d1d 100644 --- a/catalyst_voices/packages/libs/catalyst_cardano_serialization/pubspec.yaml +++ b/catalyst_voices/packages/libs/catalyst_cardano_serialization/pubspec.yaml @@ -19,7 +19,7 @@ dependencies: cbor: ^6.2.0 convert: ^3.1.1 cryptography: ^2.7.0 - equatable: ^2.0.5 + equatable: ^2.0.7 pinenacl: ^0.6.0 ulid: ^2.0.0 diff --git a/catalyst_voices/packages/libs/catalyst_compression/catalyst_compression_platform_interface/pubspec.yaml b/catalyst_voices/packages/libs/catalyst_compression/catalyst_compression_platform_interface/pubspec.yaml index 6f3c9522d43..f9550c6c32a 100644 --- a/catalyst_voices/packages/libs/catalyst_compression/catalyst_compression_platform_interface/pubspec.yaml +++ b/catalyst_voices/packages/libs/catalyst_compression/catalyst_compression_platform_interface/pubspec.yaml @@ -10,7 +10,7 @@ environment: flutter: ">=3.24.1" dependencies: - equatable: ^2.0.5 + equatable: ^2.0.7 flutter: sdk: flutter plugin_platform_interface: ^2.1.7 diff --git a/catalyst_voices/packages/libs/catalyst_key_derivation/pubspec.yaml b/catalyst_voices/packages/libs/catalyst_key_derivation/pubspec.yaml index cb2b9ae38d7..d79f3ae8c58 100644 --- a/catalyst_voices/packages/libs/catalyst_key_derivation/pubspec.yaml +++ b/catalyst_voices/packages/libs/catalyst_key_derivation/pubspec.yaml @@ -14,7 +14,7 @@ dependencies: cbor: ^6.2.0 convert: ^3.1.1 cryptography: ^2.7.0 - equatable: ^2.0.5 + equatable: ^2.0.7 flutter: sdk: flutter flutter_rust_bridge: 2.5.1 From 444644853315ffb4bbedf38e9413d89aba3cba7a Mon Sep 17 00:00:00 2001 From: Dominik Toton <166132265+dtscalac@users.noreply.github.com> Date: Thu, 28 Nov 2024 15:30:13 +0100 Subject: [PATCH 10/17] fix(flutter/catalyst_key_derivation): Accept non extended public key for rbac (#1288) * fix(flutter/catalyst_key_derivation): Accept non extended public key for rbac * fix: unit tests * chore: rename --- .../registration_transaction_builder.dart | 2 +- .../example/lib/sign_and_submit_rbac_tx.dart | 2 +- .../lib/src/rbac/registration_data.dart | 8 +- .../test/rbac/registration_data_test.dart | 57 +-- .../test/rbac/x509_certificate_test.dart | 44 +- .../rbac/x509_certificate_test.mocks.dart | 386 ------------------ .../catalyst_key_derivation_test.dart | 8 + .../bip32_ed25519_public_key.dart | 5 + .../lib/src/ed25519/ed25519_public_key.dart | 1 + 9 files changed, 47 insertions(+), 466 deletions(-) delete mode 100644 catalyst_voices/packages/libs/catalyst_cardano_serialization/test/rbac/x509_certificate_test.mocks.dart diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/registration/registration_transaction_builder.dart b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/registration/registration_transaction_builder.dart index 6d19bbbbf57..7aabfa66a87 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/registration/registration_transaction_builder.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/registration/registration_transaction_builder.dart @@ -75,7 +75,7 @@ final class RegistrationTransactionBuilder { txInputsHash: TransactionInputsHash.fromTransactionInputs(utxos), chunkedData: RegistrationData( derCerts: [derCert], - publicKeys: [keyPair.publicKey], + publicKeys: [keyPair.publicKey.toPublicKey()], roleDataSet: { // TODO(dtscalac): currently we only support the voter account role, // regardless of selected roles diff --git a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/example/lib/sign_and_submit_rbac_tx.dart b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/example/lib/sign_and_submit_rbac_tx.dart index a69ef66586f..28b9abe3c54 100644 --- a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/example/lib/sign_and_submit_rbac_tx.dart +++ b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/example/lib/sign_and_submit_rbac_tx.dart @@ -103,7 +103,7 @@ Future> _buildMetadataEnvelope({ previousTransactionId: _transactionHash, chunkedData: RegistrationData( derCerts: [derCert], - publicKeys: [keyPair.publicKey], + publicKeys: [keyPair.publicKey.toPublicKey()], roleDataSet: { RoleData( roleNumber: 0, diff --git a/catalyst_voices/packages/libs/catalyst_cardano_serialization/lib/src/rbac/registration_data.dart b/catalyst_voices/packages/libs/catalyst_cardano_serialization/lib/src/rbac/registration_data.dart index 17af8f6ac8e..ae3167e0dea 100644 --- a/catalyst_voices/packages/libs/catalyst_cardano_serialization/lib/src/rbac/registration_data.dart +++ b/catalyst_voices/packages/libs/catalyst_cardano_serialization/lib/src/rbac/registration_data.dart @@ -16,7 +16,7 @@ final class RegistrationData extends Equatable implements CborEncodable { final List? cborCerts; /// Ordered list of simple public keys that are registered. - final List? publicKeys; + final List? publicKeys; /// Revocation list of certs being revoked by an issuer. final List? revocationSet; @@ -45,9 +45,7 @@ final class RegistrationData extends Equatable implements CborEncodable { return RegistrationData( derCerts: derCerts?.map(X509DerCertificate.fromCbor).toList(), cborCerts: cborCerts?.map(C509Certificate.fromCbor).toList(), - publicKeys: publicKeys - ?.map(Bip32Ed25519XPublicKeyFactory.instance.fromCbor) - .toList(), + publicKeys: publicKeys?.map(Ed25519PublicKey.fromCbor).toList(), revocationSet: revocationSet?.map(CertificateHash.fromCbor).toList(), roleDataSet: roleDataSet?.map(RoleData.fromCbor).toSet(), ); @@ -68,7 +66,7 @@ final class RegistrationData extends Equatable implements CborEncodable { cborCerts, (item) => item.toCbor(), ), - const CborSmallInt(30): _createCborList( + const CborSmallInt(30): _createCborList( publicKeys, (item) => item.toCbor(tags: [CborCustomTags.ed25519Bip32PublicKey]), ), diff --git a/catalyst_voices/packages/libs/catalyst_cardano_serialization/test/rbac/registration_data_test.dart b/catalyst_voices/packages/libs/catalyst_cardano_serialization/test/rbac/registration_data_test.dart index 0e61184ce03..3ac304b2e59 100644 --- a/catalyst_voices/packages/libs/catalyst_cardano_serialization/test/rbac/registration_data_test.dart +++ b/catalyst_voices/packages/libs/catalyst_cardano_serialization/test/rbac/registration_data_test.dart @@ -1,22 +1,12 @@ import 'package:catalyst_cardano_serialization/catalyst_cardano_serialization.dart'; import 'package:catalyst_key_derivation/catalyst_key_derivation.dart'; import 'package:cbor/cbor.dart'; -import 'package:equatable/equatable.dart'; -import 'package:mocktail/mocktail.dart'; import 'package:test/test.dart'; import '../test_utils/test_data.dart'; void main() { group(RegistrationData, () { - setUpAll(() { - Bip32Ed25519XPrivateKeyFactory.instance = - _FakeBip32Ed25519XPrivateKeyFactory(); - - Bip32Ed25519XPublicKeyFactory.instance = - _FakeBip32Ed25519XPublicKeyFactory(); - }); - test('from and to cbor', () { final derCert = X509DerCertificate.fromHex(derCertHex); final c509Cert = C509Certificate.fromHex(c509CertHex); @@ -24,7 +14,7 @@ void main() { final original = RegistrationData( derCerts: [derCert], cborCerts: [c509Cert], - publicKeys: [Bip32Ed25519XPublicKeyFactory.instance.seeded(0)], + publicKeys: [Ed25519PublicKey.seeded(0)], revocationSet: [ CertificateHash.fromX509DerCertificate(derCert), CertificateHash.fromC509Certificate(c509Cert), @@ -55,48 +45,3 @@ void main() { }); }); } - -class _FakeBip32Ed25519XPrivateKeyFactory - extends Bip32Ed25519XPrivateKeyFactory { - @override - Bip32Ed25519XPrivateKey fromBytes(List bytes) { - return _FakeBip32Ed22519XPrivateKey(bytes: bytes); - } -} - -class _FakeBip32Ed25519XPublicKeyFactory extends Bip32Ed25519XPublicKeyFactory { - @override - _FakeBip32Ed25519XPublicKey fromBytes(List bytes) { - return _FakeBip32Ed25519XPublicKey(bytes: bytes); - } -} - -class _FakeBip32Ed22519XPrivateKey extends Fake - implements Bip32Ed25519XPrivateKey { - @override - final List bytes; - - _FakeBip32Ed22519XPrivateKey({required this.bytes}); - - @override - CborValue toCbor() { - return CborBytes(bytes); - } -} - -class _FakeBip32Ed25519XPublicKey extends Fake - with EquatableMixin - implements Bip32Ed25519XPublicKey { - @override - final List bytes; - - _FakeBip32Ed25519XPublicKey({required this.bytes}); - - @override - CborValue toCbor({List tags = const []}) { - return CborBytes(bytes, tags: tags); - } - - @override - List get props => bytes; -} diff --git a/catalyst_voices/packages/libs/catalyst_cardano_serialization/test/rbac/x509_certificate_test.dart b/catalyst_voices/packages/libs/catalyst_cardano_serialization/test/rbac/x509_certificate_test.dart index eb31671c01d..298ba9cd092 100644 --- a/catalyst_voices/packages/libs/catalyst_cardano_serialization/test/rbac/x509_certificate_test.dart +++ b/catalyst_voices/packages/libs/catalyst_cardano_serialization/test/rbac/x509_certificate_test.dart @@ -1,27 +1,13 @@ import 'package:catalyst_cardano_serialization/src/rbac/x509_certificate.dart'; import 'package:catalyst_key_derivation/catalyst_key_derivation.dart'; -import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'package:test/test.dart'; -import 'x509_certificate_test.mocks.dart'; - -@GenerateNiceMocks([ - MockSpec(), - MockSpec(), - MockSpec(), -]) void main() { group(X509Certificate, () { - final privateKey = MockBip32Ed25519XPrivateKey(); - final publicKey = MockBip32Ed25519XPublicKey(); - final signature = MockBip32Ed25519XSignature(); - - setUp(() { - // ignore: discarded_futures - when(privateKey.sign(any)).thenAnswer((_) async => signature); - when(signature.bytes).thenReturn([1, 2, 3]); - }); + final signature = _FakeBip32Ed25519XSignature(); + final privateKey = _FakeBip32Ed25519XPrivateKey(signature: signature); + final publicKey = _FakeBip32Ed25519XPublicKey(); test('generateSelfSigned X509 certificate', () async { /* cSpell:disable */ @@ -65,3 +51,27 @@ void main() { }); }); } + +class _FakeBip32Ed25519XPrivateKey extends Fake + implements Bip32Ed25519XPrivateKey { + final Bip32Ed25519XSignature signature; + + _FakeBip32Ed25519XPrivateKey({required this.signature}); + + @override + Future sign(List message) async { + return signature; + } +} + +class _FakeBip32Ed25519XPublicKey extends Fake + implements Bip32Ed25519XPublicKey { + @override + List get bytes => [1, 2, 3]; +} + +class _FakeBip32Ed25519XSignature extends Fake + implements Bip32Ed25519XSignature { + @override + List get bytes => [4, 5, 6]; +} diff --git a/catalyst_voices/packages/libs/catalyst_cardano_serialization/test/rbac/x509_certificate_test.mocks.dart b/catalyst_voices/packages/libs/catalyst_cardano_serialization/test/rbac/x509_certificate_test.mocks.dart deleted file mode 100644 index cbd52bec42e..00000000000 --- a/catalyst_voices/packages/libs/catalyst_cardano_serialization/test/rbac/x509_certificate_test.mocks.dart +++ /dev/null @@ -1,386 +0,0 @@ -// Mocks generated by Mockito 5.4.4 from annotations -// in catalyst_cardano_serialization/test/rbac/x509_certificate_test.dart. -// Do not manually edit this file. - -// ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i7; - -import 'package:catalyst_key_derivation/src/bip32_ed25519/bip32_ed25519_private_key.dart' - as _i5; -import 'package:catalyst_key_derivation/src/bip32_ed25519/bip32_ed25519_public_key.dart' - as _i4; -import 'package:catalyst_key_derivation/src/bip32_ed25519/bip32_ed25519_signature.dart' - as _i3; -import 'package:cbor/cbor.dart' as _i2; -import 'package:mockito/mockito.dart' as _i1; -import 'package:mockito/src/dummies.dart' as _i6; - -// ignore_for_file: type=lint -// ignore_for_file: avoid_redundant_argument_values -// ignore_for_file: avoid_setters_without_getters -// ignore_for_file: comment_references -// ignore_for_file: deprecated_member_use -// ignore_for_file: deprecated_member_use_from_same_package -// ignore_for_file: implementation_imports -// ignore_for_file: invalid_use_of_visible_for_testing_member -// ignore_for_file: prefer_const_constructors -// ignore_for_file: unnecessary_parenthesis -// ignore_for_file: camel_case_types -// ignore_for_file: subtype_of_sealed_class - -class _FakeCborValue_0 extends _i1.SmartFake implements _i2.CborValue { - _FakeCborValue_0( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); -} - -class _FakeBip32Ed25519XSignature_1 extends _i1.SmartFake - implements _i3.Bip32Ed25519XSignature { - _FakeBip32Ed25519XSignature_1( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); -} - -class _FakeBip32Ed25519XPublicKey_2 extends _i1.SmartFake - implements _i4.Bip32Ed25519XPublicKey { - _FakeBip32Ed25519XPublicKey_2( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); -} - -class _FakeBip32Ed25519XPrivateKey_3 extends _i1.SmartFake - implements _i5.Bip32Ed25519XPrivateKey { - _FakeBip32Ed25519XPrivateKey_3( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); -} - -/// A class which mocks [Bip32Ed25519XPrivateKey]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockBip32Ed25519XPrivateKey extends _i1.Mock - implements _i5.Bip32Ed25519XPrivateKey { - @override - List get bytes => (super.noSuchMethod( - Invocation.getter(#bytes), - returnValue: [], - returnValueForMissingStub: [], - ) as List); - - @override - List get props => (super.noSuchMethod( - Invocation.getter(#props), - returnValue: [], - returnValueForMissingStub: [], - ) as List); - - @override - _i2.CborValue toCbor() => (super.noSuchMethod( - Invocation.method( - #toCbor, - [], - ), - returnValue: _FakeCborValue_0( - this, - Invocation.method( - #toCbor, - [], - ), - ), - returnValueForMissingStub: _FakeCborValue_0( - this, - Invocation.method( - #toCbor, - [], - ), - ), - ) as _i2.CborValue); - - @override - String toHex() => (super.noSuchMethod( - Invocation.method( - #toHex, - [], - ), - returnValue: _i6.dummyValue( - this, - Invocation.method( - #toHex, - [], - ), - ), - returnValueForMissingStub: _i6.dummyValue( - this, - Invocation.method( - #toHex, - [], - ), - ), - ) as String); - - @override - _i7.Future<_i3.Bip32Ed25519XSignature> sign(List? message) => - (super.noSuchMethod( - Invocation.method( - #sign, - [message], - ), - returnValue: _i7.Future<_i3.Bip32Ed25519XSignature>.value( - _FakeBip32Ed25519XSignature_1( - this, - Invocation.method( - #sign, - [message], - ), - )), - returnValueForMissingStub: _i7.Future<_i3.Bip32Ed25519XSignature>.value( - _FakeBip32Ed25519XSignature_1( - this, - Invocation.method( - #sign, - [message], - ), - )), - ) as _i7.Future<_i3.Bip32Ed25519XSignature>); - - @override - _i7.Future verify( - List? message, { - required _i3.Bip32Ed25519XSignature? signature, - }) => - (super.noSuchMethod( - Invocation.method( - #verify, - [message], - {#signature: signature}, - ), - returnValue: _i7.Future.value(false), - returnValueForMissingStub: _i7.Future.value(false), - ) as _i7.Future); - - @override - _i7.Future<_i4.Bip32Ed25519XPublicKey> derivePublicKey() => - (super.noSuchMethod( - Invocation.method( - #derivePublicKey, - [], - ), - returnValue: _i7.Future<_i4.Bip32Ed25519XPublicKey>.value( - _FakeBip32Ed25519XPublicKey_2( - this, - Invocation.method( - #derivePublicKey, - [], - ), - )), - returnValueForMissingStub: _i7.Future<_i4.Bip32Ed25519XPublicKey>.value( - _FakeBip32Ed25519XPublicKey_2( - this, - Invocation.method( - #derivePublicKey, - [], - ), - )), - ) as _i7.Future<_i4.Bip32Ed25519XPublicKey>); - - @override - _i7.Future<_i5.Bip32Ed25519XPrivateKey> derivePrivateKey( - {required String? path}) => - (super.noSuchMethod( - Invocation.method( - #derivePrivateKey, - [], - {#path: path}, - ), - returnValue: _i7.Future<_i5.Bip32Ed25519XPrivateKey>.value( - _FakeBip32Ed25519XPrivateKey_3( - this, - Invocation.method( - #derivePrivateKey, - [], - {#path: path}, - ), - )), - returnValueForMissingStub: - _i7.Future<_i5.Bip32Ed25519XPrivateKey>.value( - _FakeBip32Ed25519XPrivateKey_3( - this, - Invocation.method( - #derivePrivateKey, - [], - {#path: path}, - ), - )), - ) as _i7.Future<_i5.Bip32Ed25519XPrivateKey>); - - @override - void drop() => super.noSuchMethod( - Invocation.method( - #drop, - [], - ), - returnValueForMissingStub: null, - ); -} - -/// A class which mocks [Bip32Ed25519XPublicKey]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockBip32Ed25519XPublicKey extends _i1.Mock - implements _i4.Bip32Ed25519XPublicKey { - @override - List get bytes => (super.noSuchMethod( - Invocation.getter(#bytes), - returnValue: [], - returnValueForMissingStub: [], - ) as List); - - @override - List get props => (super.noSuchMethod( - Invocation.getter(#props), - returnValue: [], - returnValueForMissingStub: [], - ) as List); - - @override - _i2.CborValue toCbor({List? tags = const []}) => (super.noSuchMethod( - Invocation.method( - #toCbor, - [], - {#tags: tags}, - ), - returnValue: _FakeCborValue_0( - this, - Invocation.method( - #toCbor, - [], - {#tags: tags}, - ), - ), - returnValueForMissingStub: _FakeCborValue_0( - this, - Invocation.method( - #toCbor, - [], - {#tags: tags}, - ), - ), - ) as _i2.CborValue); - - @override - String toHex() => (super.noSuchMethod( - Invocation.method( - #toHex, - [], - ), - returnValue: _i6.dummyValue( - this, - Invocation.method( - #toHex, - [], - ), - ), - returnValueForMissingStub: _i6.dummyValue( - this, - Invocation.method( - #toHex, - [], - ), - ), - ) as String); - - @override - _i7.Future verify( - List? message, { - required _i3.Bip32Ed25519XSignature? signature, - }) => - (super.noSuchMethod( - Invocation.method( - #verify, - [message], - {#signature: signature}, - ), - returnValue: _i7.Future.value(false), - returnValueForMissingStub: _i7.Future.value(false), - ) as _i7.Future); -} - -/// A class which mocks [Bip32Ed25519XSignature]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockBip32Ed25519XSignature extends _i1.Mock - implements _i3.Bip32Ed25519XSignature { - @override - List get bytes => (super.noSuchMethod( - Invocation.getter(#bytes), - returnValue: [], - returnValueForMissingStub: [], - ) as List); - - @override - List get props => (super.noSuchMethod( - Invocation.getter(#props), - returnValue: [], - returnValueForMissingStub: [], - ) as List); - - @override - _i2.CborValue toCbor() => (super.noSuchMethod( - Invocation.method( - #toCbor, - [], - ), - returnValue: _FakeCborValue_0( - this, - Invocation.method( - #toCbor, - [], - ), - ), - returnValueForMissingStub: _FakeCborValue_0( - this, - Invocation.method( - #toCbor, - [], - ), - ), - ) as _i2.CborValue); - - @override - String toHex() => (super.noSuchMethod( - Invocation.method( - #toHex, - [], - ), - returnValue: _i6.dummyValue( - this, - Invocation.method( - #toHex, - [], - ), - ), - returnValueForMissingStub: _i6.dummyValue( - this, - Invocation.method( - #toHex, - [], - ), - ), - ) as String); -} diff --git a/catalyst_voices/packages/libs/catalyst_key_derivation/example/integration_test/catalyst_key_derivation_test.dart b/catalyst_voices/packages/libs/catalyst_key_derivation/example/integration_test/catalyst_key_derivation_test.dart index 2c5decf688b..ac73817a35e 100644 --- a/catalyst_voices/packages/libs/catalyst_key_derivation/example/integration_test/catalyst_key_derivation_test.dart +++ b/catalyst_voices/packages/libs/catalyst_key_derivation/example/integration_test/catalyst_key_derivation_test.dart @@ -54,6 +54,7 @@ void main() { final xprv = await keyDerivation.deriveMasterKey(mnemonic: mnemonic); final xpub = await xprv.derivePublicKey(); + final pub = xpub.toPublicKey(); const data = [1, 2, 3, 4]; final sig = await xprv.sign(data); @@ -63,6 +64,13 @@ void main() { final xpubVerification = await xpub.verify(data, signature: sig); expect(xpubVerification, isTrue); + + final pubVerification = await pub.verify( + data, + signature: Ed25519Signature.fromBytes(sig.bytes), + ); + + expect(pubVerification, isTrue); }); testWidgets('derivePrivateKey', (tester) async { diff --git a/catalyst_voices/packages/libs/catalyst_key_derivation/lib/src/bip32_ed25519/bip32_ed25519_public_key.dart b/catalyst_voices/packages/libs/catalyst_key_derivation/lib/src/bip32_ed25519/bip32_ed25519_public_key.dart index 3d32ebef57c..577092f990a 100644 --- a/catalyst_voices/packages/libs/catalyst_key_derivation/lib/src/bip32_ed25519/bip32_ed25519_public_key.dart +++ b/catalyst_voices/packages/libs/catalyst_key_derivation/lib/src/bip32_ed25519/bip32_ed25519_public_key.dart @@ -1,6 +1,7 @@ import 'dart:typed_data'; import 'package:catalyst_key_derivation/src/bip32_ed25519/bip32_ed25519_signature.dart'; +import 'package:catalyst_key_derivation/src/ed25519/ed25519_public_key.dart'; import 'package:catalyst_key_derivation/src/rust/api/key_derivation.dart' as rust; import 'package:cbor/cbor.dart'; @@ -31,6 +32,10 @@ class Bip32Ed25519XPublicKey extends Equatable { /// Returns the bytes of the public key. List get bytes => _bytes.inner; + /// Extracts the public key bytes from the extended public key. + Ed25519PublicKey toPublicKey() => + Ed25519PublicKey.fromBytes(_bytes.publicKey); + /// Verifies whether a given [signature] was created using this public key /// for the provided [message]. /// diff --git a/catalyst_voices/packages/libs/catalyst_key_derivation/lib/src/ed25519/ed25519_public_key.dart b/catalyst_voices/packages/libs/catalyst_key_derivation/lib/src/ed25519/ed25519_public_key.dart index f295da8dfe7..8a8ed253dd5 100644 --- a/catalyst_voices/packages/libs/catalyst_key_derivation/lib/src/ed25519/ed25519_public_key.dart +++ b/catalyst_voices/packages/libs/catalyst_key_derivation/lib/src/ed25519/ed25519_public_key.dart @@ -1,3 +1,4 @@ +import 'package:catalyst_key_derivation/catalyst_key_derivation.dart'; import 'package:catalyst_key_derivation/src/ed25519/ed25519_signature.dart'; import 'package:cbor/cbor.dart'; import 'package:convert/convert.dart'; From b443449c62098ec93968c988d2fb68d3f39a821f Mon Sep 17 00:00:00 2001 From: Dominik Toton <166132265+dtscalac@users.noreply.github.com> Date: Thu, 28 Nov 2024 15:56:45 +0100 Subject: [PATCH 11/17] fix(dart/catalyst_cardano_serialization): remove key reference from RBAC, use local key ref instead (#1292) * fix(dart/catalyst_cardano_serialization): remove key reference from RBAC, use local key ref * fix: update RBAC issuer properties, catalyst users don't have any of these identifiable * fix: payment key should refer to the first transaction output which is the change address * chore: rename keyOffset to offset --- .../registration_transaction_builder.dart | 27 +++---- .../example/lib/sign_and_submit_rbac_tx.dart | 25 +++--- .../lib/src/rbac/registration_data.dart | 78 +++---------------- .../test/rbac/registration_data_test.dart | 13 ++-- 4 files changed, 40 insertions(+), 103 deletions(-) diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/registration/registration_transaction_builder.dart b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/registration/registration_transaction_builder.dart index 7aabfa66a87..3e60e15c51a 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/registration/registration_transaction_builder.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/registration/registration_transaction_builder.dart @@ -83,16 +83,13 @@ final class RegistrationTransactionBuilder { // they should be registered here RoleData( roleNumber: AccountRole.root.roleNumber, - roleSigningKey: KeyReference( - localRef: const LocalKeyReference( - keyType: LocalKeyReferenceType.x509Certs, - keyOffset: 0, - ), + roleSigningKey: const LocalKeyReference( + keyType: LocalKeyReferenceType.x509Certs, + offset: 0, ), - roleEncryptionKey: KeyReference( - hash: CertificateHash.fromX509DerCertificate(derCert), - ), - paymentKey: 0, + // Refer to first key in transaction outputs, + // in our case it's the change address (which the user controls). + paymentKey: -1, ), }, ), @@ -143,12 +140,12 @@ final class RegistrationTransactionBuilder { /* cSpell:disable */ const issuer = X509DistinguishedName( - countryName: 'US', - stateOrProvinceName: 'California', - localityName: 'San Francisco', - organizationName: 'MyCompany', - organizationalUnitName: 'MyDepartment', - commonName: 'mydomain.com', + countryName: '', + stateOrProvinceName: '', + localityName: '', + organizationName: '', + organizationalUnitName: '', + commonName: '', ); final tbs = X509TBSCertificate( diff --git a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/example/lib/sign_and_submit_rbac_tx.dart b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/example/lib/sign_and_submit_rbac_tx.dart index 28b9abe3c54..2afc3ddae41 100644 --- a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/example/lib/sign_and_submit_rbac_tx.dart +++ b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/example/lib/sign_and_submit_rbac_tx.dart @@ -107,16 +107,11 @@ Future> _buildMetadataEnvelope({ roleDataSet: { RoleData( roleNumber: 0, - roleSigningKey: KeyReference( - localRef: const LocalKeyReference( - keyType: LocalKeyReferenceType.x509Certs, - keyOffset: 0, - ), + roleSigningKey: const LocalKeyReference( + keyType: LocalKeyReferenceType.x509Certs, + offset: 0, ), - roleEncryptionKey: KeyReference( - hash: CertificateHash.fromX509DerCertificate(derCert), - ), - paymentKey: 0, + paymentKey: -1, roleSpecificData: { 10: CborString('Test'), }, @@ -200,12 +195,12 @@ Future generateX509Certificate({ /* cSpell:disable */ const issuer = X509DistinguishedName( - countryName: 'US', - stateOrProvinceName: 'California', - localityName: 'San Francisco', - organizationName: 'MyCompany', - organizationalUnitName: 'MyDepartment', - commonName: 'mydomain.com', + countryName: '', + stateOrProvinceName: '', + localityName: '', + organizationName: '', + organizationalUnitName: '', + commonName: '', ); final tbs = X509TBSCertificate( diff --git a/catalyst_voices/packages/libs/catalyst_cardano_serialization/lib/src/rbac/registration_data.dart b/catalyst_voices/packages/libs/catalyst_cardano_serialization/lib/src/rbac/registration_data.dart index ae3167e0dea..972c3fc4286 100644 --- a/catalyst_voices/packages/libs/catalyst_cardano_serialization/lib/src/rbac/registration_data.dart +++ b/catalyst_voices/packages/libs/catalyst_cardano_serialization/lib/src/rbac/registration_data.dart @@ -140,7 +140,7 @@ class RoleData extends Equatable implements CborEncodable { /// /// If the certificate is revoked, the role is unusable for signing unless /// and until a new signing certificate is registered for the role. - final KeyReference? roleSigningKey; + final LocalKeyReference? roleSigningKey; /// A Role may require the ability to transfer encrypted data. /// The registration can include the Public key use by the role to encrypt @@ -155,7 +155,7 @@ class RoleData extends Equatable implements CborEncodable { /// key encryption, and not just a signing key. /// If the key referenced does not support public key encryption, /// the registration is invalid. - final KeyReference? roleEncryptionKey; + final LocalKeyReference? roleEncryptionKey; /// Reference to a transaction input/output as the payment key to use for a role. /// Payment key (n) >= 0 = Use Transaction Input Key offset (n) @@ -207,10 +207,11 @@ class RoleData extends Equatable implements CborEncodable { return RoleData( roleNumber: roleNumber.value, - roleSigningKey: - roleSigningKey != null ? KeyReference.fromCbor(roleSigningKey) : null, + roleSigningKey: roleSigningKey != null + ? LocalKeyReference.fromCbor(roleSigningKey) + : null, roleEncryptionKey: roleEncryptionKey != null - ? KeyReference.fromCbor(roleEncryptionKey) + ? LocalKeyReference.fromCbor(roleEncryptionKey) : null, paymentKey: paymentKey?.value, roleSpecificData: roleSpecificData.isNotEmpty @@ -246,61 +247,6 @@ class RoleData extends Equatable implements CborEncodable { ]; } -/// References a local key in this registration or -/// a given key in an earlier registration. -/// -/// Either [localRef] or [hash] must be set, but not both and not none. -class KeyReference extends Equatable implements CborEncodable { - /// Offset reference to a key defined in this registration. - /// More efficient than a key hash. - final LocalKeyReference? localRef; - - /// Reference to a key defined in an earlier registration. - final CertificateHash? hash; - - /// The default constructor for [KeyReference]. - KeyReference({this.localRef, this.hash}) { - if (!((localRef == null) ^ (hash == null))) { - throw ArgumentError( - 'Either localRef or hash must be set, but not both and not none.', - ); - } - } - - /// Deserializes the type from cbor. - factory KeyReference.fromCbor(CborValue value) { - return KeyReference( - localRef: _tryParseLocalRef(value), - hash: _tryParseHash(value), - ); - } - - static LocalKeyReference? _tryParseLocalRef(CborValue value) { - try { - return LocalKeyReference.fromCbor(value); - } catch (ignored) { - return null; - } - } - - static CertificateHash? _tryParseHash(CborValue value) { - try { - return CertificateHash.fromCbor(value); - } catch (ignored) { - return null; - } - } - - /// Serializes the type as cbor. - @override - CborValue toCbor() { - return localRef?.toCbor() ?? hash!.toCbor(); - } - - @override - List get props => [localRef, hash]; -} - /// Offset reference to a key defined in this registration. /// /// More efficient than a key hash. @@ -309,23 +255,23 @@ class LocalKeyReference extends Equatable implements CborEncodable { final LocalKeyReferenceType keyType; /// Offset of the key in the specified set. 0 = first entry. - final int keyOffset; + final int offset; /// The default constructor for [LocalKeyReference]. const LocalKeyReference({ required this.keyType, - required this.keyOffset, + required this.offset, }); /// Deserializes the type from cbor. factory LocalKeyReference.fromCbor(CborValue value) { final list = value as CborList; final keyType = list[0] as CborSmallInt; - final keyOffset = list[1] as CborSmallInt; + final offset = list[1] as CborSmallInt; return LocalKeyReference( keyType: LocalKeyReferenceType.fromTag(keyType.value), - keyOffset: keyOffset.value, + offset: offset.value, ); } @@ -334,12 +280,12 @@ class LocalKeyReference extends Equatable implements CborEncodable { CborValue toCbor() { return CborList([ CborSmallInt(keyType.tag), - CborSmallInt(keyOffset), + CborSmallInt(offset), ]); } @override - List get props => [keyType, keyOffset]; + List get props => [keyType, offset]; } /// Defines the type of the referenced local key. diff --git a/catalyst_voices/packages/libs/catalyst_cardano_serialization/test/rbac/registration_data_test.dart b/catalyst_voices/packages/libs/catalyst_cardano_serialization/test/rbac/registration_data_test.dart index 3ac304b2e59..df486e9cdb2 100644 --- a/catalyst_voices/packages/libs/catalyst_cardano_serialization/test/rbac/registration_data_test.dart +++ b/catalyst_voices/packages/libs/catalyst_cardano_serialization/test/rbac/registration_data_test.dart @@ -22,14 +22,13 @@ void main() { roleDataSet: { RoleData( roleNumber: 0, - roleSigningKey: KeyReference( - localRef: const LocalKeyReference( - keyType: LocalKeyReferenceType.x509Certs, - keyOffset: 0, - ), + roleSigningKey: const LocalKeyReference( + keyType: LocalKeyReferenceType.x509Certs, + offset: 0, ), - roleEncryptionKey: KeyReference( - hash: CertificateHash.fromX509DerCertificate(derCert), + roleEncryptionKey: const LocalKeyReference( + keyType: LocalKeyReferenceType.x509Certs, + offset: 0, ), paymentKey: 0, roleSpecificData: { From 880b04bad2b98c32ae0f949cd7bb9786557dafb9 Mon Sep 17 00:00:00 2001 From: Apisit Ritruengroj <38898766+apskhem@users.noreply.github.com> Date: Fri, 29 Nov 2024 14:27:20 +0700 Subject: [PATCH 12/17] chore: bump version (#1297) --- catalyst-gateway/bin/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/catalyst-gateway/bin/Cargo.toml b/catalyst-gateway/bin/Cargo.toml index 2eaedb86a82..32a0293c373 100644 --- a/catalyst-gateway/bin/Cargo.toml +++ b/catalyst-gateway/bin/Cargo.toml @@ -15,7 +15,7 @@ repository.workspace = true workspace = true [dependencies] -cardano-chain-follower = { version = "0.0.4", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "2024-10-15-01" } +cardano-chain-follower = { version = "0.0.5", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "cardano-chain-follower-v0.0.5" } c509-certificate = { version = "0.0.3", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "v0.0.3" } pallas = { version = "0.30.1", git = "https://github.com/input-output-hk/catalyst-pallas.git", rev = "9b5183c8b90b90fe2cc319d986e933e9518957b3" } From 43b9b58d8c066d29a6663c7e578e11ad4081acb8 Mon Sep 17 00:00:00 2001 From: Apisit Ritruengroj <38898766+apskhem@users.noreply.github.com> Date: Fri, 29 Nov 2024 21:40:15 +0700 Subject: [PATCH 13/17] fix(cat-gateway): Fix native asset indexing to be more flexible (#1150) * refactor: rename schema to asset * refactor: vector asset * chore: rename asset fields * refactor: object mapping structs * chore: minor rename * fix: update operation cql * fix: schema version * chore: change asset_id back to policy_id * chore: find rename * fix: schema version * fix: i128 * feat: asset value from i128 * refactor: change &[u8] for asset name * refactor: try from asset value * fix: import * chore: fmtfix * Update catalyst-gateway/bin/src/db/index/block/txo/insert_txo_asset.rs Co-authored-by: Steven Johnson * revert: i128 to bigint * fix: unused import * feat: api test * chore: cspell fix * chore: cspell fix * chore: fmtfix --------- Co-authored-by: Oleksandr Prokhorenko Co-authored-by: Steven Johnson --- .../index/block/txo/cql/insert_txo_asset.cql | 4 +-- .../txo/cql/insert_unstaked_txo_asset.cql | 4 +-- .../db/index/block/txo/insert_txo_asset.rs | 8 ++--- .../block/txo/insert_unstaked_txo_asset.rs | 6 ++-- .../bin/src/db/index/block/txo/mod.rs | 12 ++----- .../cql/get_assets_by_stake_address.cql | 2 +- .../staked_ada/get_assets_by_stake_address.rs | 2 +- .../schema/cql/txo_assets_by_stake_table.cql | 4 +-- .../cql/unstaked_txo_assets_by_txn_hash.cql | 4 +-- .../bin/src/db/index/schema/mod.rs | 2 +- .../service/api/cardano/staking/assets_get.rs | 30 +++++++++-------- .../common/types/cardano/asset_name.rs | 1 - .../types/cardano/cip19_stake_address.rs | 33 ++++++++++++++++++- .../api_tests/get_cardano_assets.hurl | 15 +++++++++ 14 files changed, 85 insertions(+), 42 deletions(-) create mode 100644 catalyst-gateway/tests/api_tests/api_tests/get_cardano_assets.hurl diff --git a/catalyst-gateway/bin/src/db/index/block/txo/cql/insert_txo_asset.cql b/catalyst-gateway/bin/src/db/index/block/txo/cql/insert_txo_asset.cql index 3bdb6342c4b..92c9dd0c534 100644 --- a/catalyst-gateway/bin/src/db/index/block/txo/cql/insert_txo_asset.cql +++ b/catalyst-gateway/bin/src/db/index/block/txo/cql/insert_txo_asset.cql @@ -6,7 +6,7 @@ INSERT INTO txo_assets_by_stake ( txn, txo, policy_id, - policy_name, + asset_name, value ) VALUES ( :stake_address, @@ -14,6 +14,6 @@ INSERT INTO txo_assets_by_stake ( :txn, :txo, :policy_id, - :policy_name, + :asset_name, :value ); diff --git a/catalyst-gateway/bin/src/db/index/block/txo/cql/insert_unstaked_txo_asset.cql b/catalyst-gateway/bin/src/db/index/block/txo/cql/insert_unstaked_txo_asset.cql index e170a0b46c2..6045229d69e 100644 --- a/catalyst-gateway/bin/src/db/index/block/txo/cql/insert_unstaked_txo_asset.cql +++ b/catalyst-gateway/bin/src/db/index/block/txo/cql/insert_unstaked_txo_asset.cql @@ -3,7 +3,7 @@ INSERT INTO unstaked_txo_assets_by_txn_hash ( txn_hash, txo, policy_id, - policy_name, + asset_name, slot_no, txn, value @@ -11,7 +11,7 @@ INSERT INTO unstaked_txo_assets_by_txn_hash ( :txn_hash, :txo, :policy_id, - :policy_name, + :asset_name, :slot_no, :txn, :value diff --git a/catalyst-gateway/bin/src/db/index/block/txo/insert_txo_asset.rs b/catalyst-gateway/bin/src/db/index/block/txo/insert_txo_asset.rs index ba7bbde7c45..69e547594a7 100644 --- a/catalyst-gateway/bin/src/db/index/block/txo/insert_txo_asset.rs +++ b/catalyst-gateway/bin/src/db/index/block/txo/insert_txo_asset.rs @@ -27,8 +27,8 @@ pub(super) struct Params { txo: i16, /// Policy hash of the asset policy_id: Vec, - /// Policy name of the asset - policy_name: String, + /// Name of the asset, within the Policy. + asset_name: Vec, /// Value of the asset value: num_bigint::BigInt, } @@ -41,7 +41,7 @@ impl Params { #[allow(clippy::too_many_arguments)] pub(super) fn new( stake_address: &[u8], slot_no: u64, txn: i16, txo: i16, policy_id: &[u8], - policy_name: &str, value: i128, + asset_name: &[u8], value: i128, ) -> Self { Self { stake_address: stake_address.to_vec(), @@ -49,7 +49,7 @@ impl Params { txn, txo, policy_id: policy_id.to_vec(), - policy_name: policy_name.to_owned(), + asset_name: asset_name.to_vec(), value: value.into(), } } diff --git a/catalyst-gateway/bin/src/db/index/block/txo/insert_unstaked_txo_asset.rs b/catalyst-gateway/bin/src/db/index/block/txo/insert_unstaked_txo_asset.rs index 250ca8ae1c3..7436d36d200 100644 --- a/catalyst-gateway/bin/src/db/index/block/txo/insert_unstaked_txo_asset.rs +++ b/catalyst-gateway/bin/src/db/index/block/txo/insert_unstaked_txo_asset.rs @@ -24,7 +24,7 @@ pub(super) struct Params { /// Policy hash of the asset policy_id: Vec, /// Policy name of the asset - policy_name: String, + asset_name: Vec, /// Block Slot Number slot_no: num_bigint::BigInt, /// Transaction Offset inside the block. @@ -40,14 +40,14 @@ impl Params { /// values. #[allow(clippy::too_many_arguments)] pub(super) fn new( - txn_hash: &[u8], txo: i16, policy_id: &[u8], policy_name: &str, slot_no: u64, txn: i16, + txn_hash: &[u8], txo: i16, policy_id: &[u8], asset_name: &[u8], slot_no: u64, txn: i16, value: i128, ) -> Self { Self { txn_hash: txn_hash.to_vec(), txo, policy_id: policy_id.to_vec(), - policy_name: policy_name.to_owned(), + asset_name: asset_name.to_vec(), slot_no: slot_no.into(), txn, value: value.into(), diff --git a/catalyst-gateway/bin/src/db/index/block/txo/mod.rs b/catalyst-gateway/bin/src/db/index/block/txo/mod.rs index 66bd950822f..f3f08440e60 100644 --- a/catalyst-gateway/bin/src/db/index/block/txo/mod.rs +++ b/catalyst-gateway/bin/src/db/index/block/txo/mod.rs @@ -181,7 +181,7 @@ impl TxoInsertQuery { let policy_id = asset.policy().to_vec(); for policy_asset in asset.assets() { if policy_asset.is_output() { - let policy_name = policy_asset.to_ascii_name().unwrap_or_default(); + let asset_name = policy_asset.name(); let value = policy_asset.any_coin(); if staked { @@ -191,19 +191,13 @@ impl TxoInsertQuery { txn, txo_index, &policy_id, - &policy_name, + asset_name, value, ); self.staked_txo_asset.push(params); } else { let params = insert_unstaked_txo_asset::Params::new( - txn_hash, - txo_index, - &policy_id, - &policy_name, - slot_no, - txn, - value, + txn_hash, txo_index, &policy_id, asset_name, slot_no, txn, value, ); self.unstaked_txo_asset.push(params); } diff --git a/catalyst-gateway/bin/src/db/index/queries/cql/get_assets_by_stake_address.cql b/catalyst-gateway/bin/src/db/index/queries/cql/get_assets_by_stake_address.cql index 128bc81d782..82d4c605818 100644 --- a/catalyst-gateway/bin/src/db/index/queries/cql/get_assets_by_stake_address.cql +++ b/catalyst-gateway/bin/src/db/index/queries/cql/get_assets_by_stake_address.cql @@ -3,7 +3,7 @@ SELECT txo, slot_no, policy_id, - policy_name, + asset_name, value FROM txo_assets_by_stake WHERE stake_address = :stake_address diff --git a/catalyst-gateway/bin/src/db/index/queries/staked_ada/get_assets_by_stake_address.rs b/catalyst-gateway/bin/src/db/index/queries/staked_ada/get_assets_by_stake_address.rs index 03d1a16a7dc..09bc8f2df03 100644 --- a/catalyst-gateway/bin/src/db/index/queries/staked_ada/get_assets_by_stake_address.rs +++ b/catalyst-gateway/bin/src/db/index/queries/staked_ada/get_assets_by_stake_address.rs @@ -54,7 +54,7 @@ mod result { /// Asset hash. pub policy_id: Vec, /// Asset name. - pub policy_name: String, + pub asset_name: Vec, /// Asset value. pub value: num_bigint::BigInt, } diff --git a/catalyst-gateway/bin/src/db/index/schema/cql/txo_assets_by_stake_table.cql b/catalyst-gateway/bin/src/db/index/schema/cql/txo_assets_by_stake_table.cql index 19a686f2d53..da85a1d9323 100644 --- a/catalyst-gateway/bin/src/db/index/schema/cql/txo_assets_by_stake_table.cql +++ b/catalyst-gateway/bin/src/db/index/schema/cql/txo_assets_by_stake_table.cql @@ -7,11 +7,11 @@ CREATE TABLE IF NOT EXISTS txo_assets_by_stake ( txn smallint, -- Which Transaction in the Slot is the TXO. txo smallint, -- offset in the txo list of the transaction the txo is in. policy_id blob, -- asset policy hash (id) (28 byte binary hash) - policy_name text, -- name of the policy (UTF8) TODO: https://github.com/input-output-hk/catalyst-voices/issues/1121 + asset_name blob, -- name of the asset policy (UTF8) (32 bytes) -- None Key Data of the asset. value varint, -- Value of the asset (i128) - PRIMARY KEY (stake_address, slot_no, txn, txo, policy_id, policy_name) + PRIMARY KEY (stake_address, slot_no, txn, txo, policy_id, asset_name) ); diff --git a/catalyst-gateway/bin/src/db/index/schema/cql/unstaked_txo_assets_by_txn_hash.cql b/catalyst-gateway/bin/src/db/index/schema/cql/unstaked_txo_assets_by_txn_hash.cql index 047567d895d..4628e236ec4 100644 --- a/catalyst-gateway/bin/src/db/index/schema/cql/unstaked_txo_assets_by_txn_hash.cql +++ b/catalyst-gateway/bin/src/db/index/schema/cql/unstaked_txo_assets_by_txn_hash.cql @@ -4,7 +4,7 @@ CREATE TABLE IF NOT EXISTS unstaked_txo_assets_by_txn_hash ( txn_hash blob, -- 32 byte hash of this transaction. txo smallint, -- offset in the txo list of the transaction the txo is in. policy_id blob, -- asset policy hash (id) (28 byte binary hash) - policy_name text, -- name of the policy (UTF8) + asset_name blob, -- name of the policy (UTF8) (32 bytes) -- Secondary Location information for the transaction. slot_no varint, -- slot number the txo was created in. @@ -13,5 +13,5 @@ CREATE TABLE IF NOT EXISTS unstaked_txo_assets_by_txn_hash ( -- Value of the asset. value varint, -- Value of the asset (u64) - PRIMARY KEY (txn_hash, txo, policy_id, policy_name) + PRIMARY KEY (txn_hash, txo, policy_id, asset_name) ); diff --git a/catalyst-gateway/bin/src/db/index/schema/mod.rs b/catalyst-gateway/bin/src/db/index/schema/mod.rs index 8a219105fe1..e262e2a0a76 100644 --- a/catalyst-gateway/bin/src/db/index/schema/mod.rs +++ b/catalyst-gateway/bin/src/db/index/schema/mod.rs @@ -17,7 +17,7 @@ use crate::{settings::cassandra_db, utils::blake2b_hash::generate_uuid_string_fr /// change accidentally, and is NOT to be used directly to set the schema version of the /// table namespaces. #[allow(dead_code)] -const SCHEMA_VERSION: &str = "08193dfe-698a-8177-bdf8-20c5691a06e7"; +const SCHEMA_VERSION: &str = "75ae6ac9-ddd8-8472-8a7a-8676d04f8679"; /// Keyspace Create (Templated) const CREATE_NAMESPACE_CQL: &str = include_str!("./cql/namespace.cql"); diff --git a/catalyst-gateway/bin/src/service/api/cardano/staking/assets_get.rs b/catalyst-gateway/bin/src/service/api/cardano/staking/assets_get.rs index cd0d2d04e37..f92fa93d5d9 100644 --- a/catalyst-gateway/bin/src/service/api/cardano/staking/assets_get.rs +++ b/catalyst-gateway/bin/src/service/api/cardano/staking/assets_get.rs @@ -27,7 +27,7 @@ use crate::{ stake_info::{FullStakeInfo, StakeInfo, StakedNativeTokenInfo}, }, responses::WithErrorResponses, - types::cardano::cip19_stake_address::Cip19StakeAddress, + types::cardano::{asset_name::AssetName, cip19_stake_address::Cip19StakeAddress}, }, }; @@ -79,8 +79,7 @@ struct TxoAssetInfo { /// Asset hash. id: Vec, /// Asset name. - // TODO: https://github.com/input-output-hk/catalyst-voices/issues/1121 - name: String, + name: AssetName, /// Asset amount. amount: num_bigint::BigInt, } @@ -100,8 +99,7 @@ struct TxoInfo { /// Whether the TXO was spent. spent_slot_no: Option, /// TXO assets. - // TODO: https://github.com/input-output-hk/catalyst-voices/issues/1121 - assets: HashMap, TxoAssetInfo>, + assets: HashMap, Vec>, } /// Calculate the stake info for a given stake address. @@ -186,12 +184,18 @@ async fn get_txo_by_txn( let entry = txo_info .assets .entry(row.policy_id.clone()) - .or_insert(TxoAssetInfo { - id: row.policy_id, - name: row.policy_name, - amount: num_bigint::BigInt::ZERO, - }); - entry.amount += row.value; + .or_insert_with(Vec::new); + + match entry.iter_mut().find(|item| item.id == row.policy_id) { + Some(item) => item.amount += row.value, + None => { + entry.push(TxoAssetInfo { + id: row.policy_id, + name: row.asset_name.into(), + amount: row.value, + }); + }, + } } let mut txos_by_txn = HashMap::new(); @@ -274,10 +278,10 @@ fn build_stake_info( stake_info.ada_amount += i64::try_from(txo_info.value).map_err(|err| anyhow!(err))?; - for asset in txo_info.assets.into_values() { + for asset in txo_info.assets.into_values().flatten() { stake_info.native_tokens.push(StakedNativeTokenInfo { policy_hash: asset.id.try_into()?, - asset_name: asset.name.into(), + asset_name: asset.name, amount: asset.amount.try_into()?, }); } diff --git a/catalyst-gateway/bin/src/service/common/types/cardano/asset_name.rs b/catalyst-gateway/bin/src/service/common/types/cardano/asset_name.rs index 3f8e1f72cf5..0594a0f968a 100644 --- a/catalyst-gateway/bin/src/service/common/types/cardano/asset_name.rs +++ b/catalyst-gateway/bin/src/service/common/types/cardano/asset_name.rs @@ -65,7 +65,6 @@ impl Example for AssetName { } } -// TODO: https://github.com/input-output-hk/catalyst-voices/issues/1121 impl From> for AssetName { fn from(value: Vec) -> Self { match String::from_utf8(value.clone()) { diff --git a/catalyst-gateway/bin/src/service/common/types/cardano/cip19_stake_address.rs b/catalyst-gateway/bin/src/service/common/types/cardano/cip19_stake_address.rs index 3ae12fbc3b1..83f01951003 100644 --- a/catalyst-gateway/bin/src/service/common/types/cardano/cip19_stake_address.rs +++ b/catalyst-gateway/bin/src/service/common/types/cardano/cip19_stake_address.rs @@ -42,7 +42,7 @@ pub(crate) const PATTERN: &str = concatcp!( /// Length of the encoded address. const ENCODED_ADDR_LEN: usize = 53; /// Length of the decoded address. -const DECODED_ADDR_LEN: usize = 28; +const DECODED_ADDR_LEN: usize = 29; /// Minimum length pub(crate) const MIN_LENGTH: usize = PROD_STAKE.len() + 1 + ENCODED_ADDR_LEN; /// Minimum length @@ -150,3 +150,34 @@ impl Example for Cip19StakeAddress { Self(EXAMPLE.to_owned()) } } + +#[cfg(test)] +mod tests { + use super::*; + + // cspell: disable + const VALID_PROD_STAKE_ADDRESS: &str = + "stake1u94ullc9nj9gawc08990nx8hwgw80l9zpmr8re44kydqy9cdjq6rq"; + const VALID_TEST_STAKE_ADDRESS: &str = + "stake_test1uqehkck0lajq8gr28t9uxnuvgcqrc6070x3k9r8048z8y5gssrtvn"; + const INVALID_STAKE_ADDRESS: &str = + "invalid1u9nlq5nmuzthw3vhgakfpxyq4r0zl2c0p8uqy24gpyjsa6c3df4h6"; + // cspell: enable + + #[test] + fn test_valid_stake_address_from_string() { + let stake_address_prod = Cip19StakeAddress::try_from(VALID_PROD_STAKE_ADDRESS.to_string()); + let stake_address_test = Cip19StakeAddress::try_from(VALID_TEST_STAKE_ADDRESS.to_string()); + + assert!(stake_address_prod.is_ok()); + assert!(stake_address_test.is_ok()); + assert_eq!(stake_address_prod.unwrap().0, VALID_PROD_STAKE_ADDRESS); + assert_eq!(stake_address_test.unwrap().0, VALID_TEST_STAKE_ADDRESS); + } + + #[test] + fn test_invalid_stake_address_from_string() { + let stake_address = Cip19StakeAddress::try_from(INVALID_STAKE_ADDRESS.to_string()); + assert!(stake_address.is_err()); + } +} diff --git a/catalyst-gateway/tests/api_tests/api_tests/get_cardano_assets.hurl b/catalyst-gateway/tests/api_tests/api_tests/get_cardano_assets.hurl new file mode 100644 index 00000000000..92bfc53e2ef --- /dev/null +++ b/catalyst-gateway/tests/api_tests/api_tests/get_cardano_assets.hurl @@ -0,0 +1,15 @@ +# Get staked ADA amount: zero assets +GET http://localhost:3030/api/draft/cardano/assets/stake_test1ursne3ndzr4kz8gmhmstu5026erayrnqyj46nqkkfcn0ufss2t7vt +HTTP 200 +{"persistent":{"ada_amount":9809147618,"native_tokens":[],"slot_number":76323283},"volatile":{"ada_amount":0,"native_tokens":[],"slot_number":0}} + +# Get staked ADA amount: single asset +GET http://localhost:3030/api/draft/cardano/assets/stake_test1uq7cnze6az9f8ffjrvkxx4ad77jz088frkhzupxcc7y4x8q5x808s +HTTP 200 +{"persistent":{"ada_amount":8870859858,"native_tokens":[{"amount":3,"asset_name":"GoldRelic","policy_hash":"0x2862c9b33e98096107e2d8b8c072070834db9c91c0d2f3743e75df65"}],"slot_number":76572358},"volatile":{"ada_amount":0,"native_tokens":[],"slot_number":0}} + +# Get staked ADA amount: multiple assets +GET http://localhost:3030/api/draft/cardano/assets/stake_test1ur66dds0pkf3j5tu7py9tqf7savpv7pgc5g3dd74xy0x2vsldf2mx +HTTP 200 +[Asserts] +jsonpath "$.persistent.native_tokens" count == 9 \ No newline at end of file From c189dd583550130a2aacc8a3fba10cba649b9b60 Mon Sep 17 00:00:00 2001 From: Steven Johnson Date: Mon, 2 Dec 2024 13:59:55 +0700 Subject: [PATCH 14/17] feat(docs): Document the key derivation path for Project Catalyst ED25519 Keys (#1300) * feat(docs): Document the key derivation path for Project Catalyst ED25519 keys * fix(docs): Fix and reference historical dates for accuracy --- .../08_concepts/key-derivation/.pages | 3 + .../08_concepts/key-derivation/derivation.md | 90 +++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 docs/src/architecture/08_concepts/key-derivation/.pages create mode 100644 docs/src/architecture/08_concepts/key-derivation/derivation.md diff --git a/docs/src/architecture/08_concepts/key-derivation/.pages b/docs/src/architecture/08_concepts/key-derivation/.pages new file mode 100644 index 00000000000..b37fa5b0b99 --- /dev/null +++ b/docs/src/architecture/08_concepts/key-derivation/.pages @@ -0,0 +1,3 @@ +title: Key Derivation +arrange: + - derivation.md diff --git a/docs/src/architecture/08_concepts/key-derivation/derivation.md b/docs/src/architecture/08_concepts/key-derivation/derivation.md new file mode 100644 index 00000000000..96f2d9a18b1 --- /dev/null +++ b/docs/src/architecture/08_concepts/key-derivation/derivation.md @@ -0,0 +1,90 @@ +--- +Title: Catalyst HD Key Derivation for Off Chain ED25519 Signature Keys +Category: Catalyst +Status: Proposed +Authors: + - Steven Johnson +Implementors: + - Catalyst Fund 14 +Discussions: [] +Created: 2024-11-29 +License: CC-BY-4.0 +--- + +## Abstract + +Project Catalyst uses off chain keys, as a proxy for on-chain keys. +These keys need to be derived similar to the keys controlled by a wallet. +This document defines the Derivation path. + +## Motivation: why is this CIP necessary? + +A user will need a number of self generated and controlled signature keys. +They will need to be able to recover them from a known seed phrase, and also to roll them over. + +This allows users to replace keys, and have them fully recoverable. +Which they may have to do if: + +* Their keys are lost, and the account has to be recovered, or moved to a different device. +* Their keys are compromised (or suspected to be compromised), and they have to be replaced. + +The keys are not controlled by a Blockchain wallet. +They are agnostic of any blockchain. +So, Project Catalyst must implement similar mechanisms as the wallets to safely derive keys for its use. + +## Specification + +For reference, see [CIP-1852]. +This document is a modified implementation of this specification. + +The basic structure of the Key Derivation path shall be: + +```text +m / purpose' / type' / account' / role / index +``` + +In Cardano, both `purpose'` and `coin_type'` are notable years. +This specification uses [historical dates] related to democracy and voting. +This specification follows that convention but choose years more applicable to project Catalyst and its goals. +This specification also renames `coin_type'` to just `type'`, to be more generalized. + +Changing the `purpose` and `type` values ensures that any keys derived for Project Catalyst will not +be derivable or collide with keys derived for Cardano. + +* `purpose'` = `508` : Taken from year 508 BCE, the first known instance of democracy in human history. + *"The Athenian Revolution, + a revolt that overthrew the aristocratic oligarchy and established a participatory democracy in Athens"*. +* `type'` = `139` : Taken from the year 139 BCE, the first known instance of secret voting. + *"A secret ballot is instituted for Roman citizens, who mark their vote on a tablet and place it in an urn."* +* `account'` = `0` : Reserved for future use cases. + Always to be set to `0`. +* `role` = `0`-`n` : The role in the derivation maps 1:1 with the role number in the RBAC registration the key will be used for. +* `index` = `0`-`n` : The sequentially derived key in a sequence, starting at 0. + Each new key for the same role just increments `index`. + +## Reference Implementation + +The first implementation will be Catalyst Voices. + +*TODO: Generate a set of test vectors which conform to this specification.* + +## Rationale: how does this CIP achieve its goals? + +By leveraging known working Key Derivation techniques and simply modifying the path we inherit the properties of those methods. + +## Path to Active + +### Acceptance Criteria + +Working Implementation before Fund 14. + +### Implementation Plan + +Fund 14 project catalyst will deploy this scheme for Key derivation.> + +## Copyright + +This document is licensed under [CC-BY-4.0](https://creativecommons.org/licenses/by/4.0/legalcode). + +[CIP-1852]: https://cips.cardano.org/cip/CIP-1852 +[historical dates]: https://www.oxfordreference.com/display/10.1093/acref/9780191737152.timeline.0001 From b5935f41a53a7ff58eac185d856a3f6ea5999b5a Mon Sep 17 00:00:00 2001 From: bkioshn <35752733+bkioshn@users.noreply.github.com> Date: Mon, 2 Dec 2024 14:43:16 +0700 Subject: [PATCH 15/17] fix(dart/catalyst_cardano_serialization): x509 distinguished name structure (#1290) * fix: x509 distinguished name structure Signed-off-by: bkioshn * fix: format Signed-off-by: bkioshn * feat: make it possible to override ASN1 tag for subject alt name in the x509 cert * fix: static analysis issue --------- Signed-off-by: bkioshn Co-authored-by: Dominik Toton <166132265+dtscalac@users.noreply.github.com> Co-authored-by: Dominik Toton --- .../registration_transaction_builder.dart | 5 +- .../example/lib/sign_and_submit_rbac_tx.dart | 13 +-- .../lib/src/rbac/x509_certificate.dart | 90 ++++++++++++++----- .../test/rbac/x509_certificate_test.dart | 8 +- 4 files changed, 86 insertions(+), 30 deletions(-) diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/registration/registration_transaction_builder.dart b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/registration/registration_transaction_builder.dart index 3e60e15c51a..f273d45e187 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/registration/registration_transaction_builder.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/registration/registration_transaction_builder.dart @@ -157,7 +157,10 @@ final class RegistrationTransactionBuilder { subject: issuer, extensions: X509CertificateExtensions( subjectAltName: [ - 'web+cardano://addr/${_stakeAddress.toBech32()}', + X509String( + 'web+cardano://addr/${_stakeAddress.toBech32()}', + tag: X509String.uriTag, + ), ], ), ); diff --git a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/example/lib/sign_and_submit_rbac_tx.dart b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/example/lib/sign_and_submit_rbac_tx.dart index 2afc3ddae41..a3695842c7c 100644 --- a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/example/lib/sign_and_submit_rbac_tx.dart +++ b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/example/lib/sign_and_submit_rbac_tx.dart @@ -212,11 +212,14 @@ Future generateX509Certificate({ subject: issuer, extensions: X509CertificateExtensions( subjectAltName: [ - 'mydomain.com', - 'www.mydomain.com', - 'example.com', - 'www.example.com', - 'web+cardano://addr/${stakeAddress.toBech32()}', + const X509String('mydomain.com', tag: X509String.domainNameTag), + const X509String('www.mydomain.com', tag: X509String.domainNameTag), + const X509String('example.com', tag: X509String.domainNameTag), + const X509String('www.example.com', tag: X509String.domainNameTag), + X509String( + 'web+cardano://addr/${stakeAddress.toBech32()}', + tag: X509String.uriTag, + ), ], ), ); diff --git a/catalyst_voices/packages/libs/catalyst_cardano_serialization/lib/src/rbac/x509_certificate.dart b/catalyst_voices/packages/libs/catalyst_cardano_serialization/lib/src/rbac/x509_certificate.dart index dbf6f7c7eb1..a4a9e00ce9b 100644 --- a/catalyst_voices/packages/libs/catalyst_cardano_serialization/lib/src/rbac/x509_certificate.dart +++ b/catalyst_voices/packages/libs/catalyst_cardano_serialization/lib/src/rbac/x509_certificate.dart @@ -245,54 +245,72 @@ class X509DistinguishedName with EquatableMixin { final countryName = this.countryName; if (countryName != null) { sequence.add( - ASN1Sequence() - ..add(ASN1ObjectIdentifier.fromName('c')) - ..add(ASN1PrintableString(countryName)), + ASN1Set() + ..add( + ASN1Sequence() + ..add(ASN1ObjectIdentifier.fromName('c')) + ..add(ASN1PrintableString(countryName)), + ), ); } final stateOrProvinceName = this.stateOrProvinceName; if (stateOrProvinceName != null) { sequence.add( - ASN1Sequence() - ..add(ASN1ObjectIdentifier.fromName('st')) - ..add(ASN1PrintableString(stateOrProvinceName)), + ASN1Set() + ..add( + ASN1Sequence() + ..add(ASN1ObjectIdentifier.fromName('st')) + ..add(ASN1PrintableString(stateOrProvinceName)), + ), ); } final localityName = this.localityName; if (localityName != null) { sequence.add( - ASN1Sequence() - ..add(ASN1ObjectIdentifier.fromName('l')) - ..add(ASN1PrintableString(localityName)), + ASN1Set() + ..add( + ASN1Sequence() + ..add(ASN1ObjectIdentifier.fromName('l')) + ..add(ASN1PrintableString(localityName)), + ), ); } final organizationName = this.organizationName; if (organizationName != null) { sequence.add( - ASN1Sequence() - ..add(ASN1ObjectIdentifier.fromName('o')) - ..add(ASN1PrintableString(organizationName)), + ASN1Set() + ..add( + ASN1Sequence() + ..add(ASN1ObjectIdentifier.fromName('o')) + ..add(ASN1PrintableString(organizationName)), + ), ); } final organizationalUnitName = this.organizationalUnitName; if (organizationalUnitName != null) { sequence.add( - ASN1Sequence() - ..add(ASN1ObjectIdentifier.fromName('ou')) - ..add(ASN1PrintableString(organizationalUnitName)), + ASN1Set() + ..add( + ASN1Sequence() + ..add(ASN1ObjectIdentifier.fromName('ou')) + ..add(ASN1PrintableString(organizationalUnitName)), + ), ); } final commonName = this.commonName; if (commonName != null) { sequence.add( - ASN1Sequence() - ..add(ASN1ObjectIdentifier.fromName('cn')) - ..add(ASN1PrintableString(commonName)), + ASN1Set() + ..add( + ASN1Sequence() + ..add(ASN1ObjectIdentifier.fromName('cn')) + ..add(ASN1PrintableString(commonName)), + ), ); } @@ -313,7 +331,7 @@ class X509DistinguishedName with EquatableMixin { /// Extra extensions of the certificate. class X509CertificateExtensions with EquatableMixin { /// List of alternative subject names. - final List? subjectAltName; + final List? subjectAltName; /// The default constructor for the [X509CertificateExtensions]. const X509CertificateExtensions({this.subjectAltName}); @@ -330,7 +348,7 @@ class X509CertificateExtensions with EquatableMixin { if (subjectAltName != null) { final subjectAltNameSequence = ASN1Sequence(); for (final name in subjectAltName) { - subjectAltNameSequence.add(ASN1OctetString(name)); + subjectAltNameSequence.add(name.toASN1()); } extensionsSequence.add( @@ -350,3 +368,35 @@ class X509CertificateExtensions with EquatableMixin { @override List get props => [subjectAltName]; } + +/// Represents an ASN1 encodable string +/// that can be optionally tagged with [tag]. +class X509String with EquatableMixin { + /// An ASN1 tag for the uris. + static const int uriTag = 0x86; + + /// An ASN1 tag for domain names. + static const int domainNameTag = 0x82; + + /// The string value. + final String value; + + /// The optional ASN1 tag. + final int tag; + + /// The default constructor for the [X509String]. + const X509String( + this.value, { + this.tag = OCTET_STRING_TYPE, + }); + + /// Encodes the data in ASN1 format. + ASN1Object toASN1() { + _ensureASN1FrequentNamesRegistered(); + + return ASN1OctetString(value, tag: tag); + } + + @override + List get props => [value, tag]; +} diff --git a/catalyst_voices/packages/libs/catalyst_cardano_serialization/test/rbac/x509_certificate_test.dart b/catalyst_voices/packages/libs/catalyst_cardano_serialization/test/rbac/x509_certificate_test.dart index 298ba9cd092..0c719fb2ad1 100644 --- a/catalyst_voices/packages/libs/catalyst_cardano_serialization/test/rbac/x509_certificate_test.dart +++ b/catalyst_voices/packages/libs/catalyst_cardano_serialization/test/rbac/x509_certificate_test.dart @@ -29,10 +29,10 @@ void main() { subject: issuer, extensions: const X509CertificateExtensions( subjectAltName: [ - 'mydomain.com', - 'www.mydomain.com', - 'example.com', - 'www.example.com', + X509String('mydomain.com', tag: X509String.domainNameTag), + X509String('www.mydomain.com', tag: X509String.domainNameTag), + X509String('example.com', tag: X509String.domainNameTag), + X509String('www.example.com', tag: X509String.domainNameTag), ], ), ); From 8e1884302c05642389ac89c177fcc59b24597d3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damian=20Moli=C5=84ski?= <47773413+damian-molinski@users.noreply.github.com> Date: Mon, 2 Dec 2024 10:20:12 +0100 Subject: [PATCH 16/17] feat: additional just functions for faster startup (#1310) --- catalyst_voices/README.md | 2 +- catalyst_voices/justfile | 14 +++++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/catalyst_voices/README.md b/catalyst_voices/README.md index 9871aab483a..266da52c97a 100644 --- a/catalyst_voices/README.md +++ b/catalyst_voices/README.md @@ -43,7 +43,7 @@ This repository contains the Catalyst Voices app and packages. ```sh git clone https://github.com/input-output-hk/catalyst-voices.git cd catalyst_voices -melos bootstrap +just bootstrap ``` ### Packages diff --git a/catalyst_voices/justfile b/catalyst_voices/justfile index a0086e28447..9d20ac33cac 100755 --- a/catalyst_voices/justfile +++ b/catalyst_voices/justfile @@ -1,10 +1,22 @@ #!/usr/bin/env just --justfile - # cspell: words justfile default: @just --list --unsorted +# Linking of all packages +setup-code: + melos bs + +# Builds generated code +generate-code: setup-code + melos l10n + melos build_runner + just generate-gateway-services + +# Syntax sugar for linking packages and building generated code +bootstrap: generate-code + # Runs all static code checks check-code: earthly +check-static-analysis From f9b60fa7556231ffe7b9272297413d1168161c4b Mon Sep 17 00:00:00 2001 From: Dominik Toton <166132265+dtscalac@users.noreply.github.com> Date: Mon, 2 Dec 2024 10:36:07 +0100 Subject: [PATCH 17/17] fix(cat-voices): update key derivation path (#1301) * fix(cat-voices): update key derivation path * docs: add source * chore: code cleanup * docs: move relevant docs * chore: extract account constant Co-authored-by: Steven Johnson * chore: reformat --------- Co-authored-by: Steven Johnson --- .../lib/src/user/account_role.dart | 18 +++++++++--------- .../lib/src/crypto/key_derivation.dart | 8 +++++--- .../registration_transaction_builder.dart | 2 +- .../test/src/crypto/key_derivation_test.dart | 2 +- 4 files changed, 16 insertions(+), 14 deletions(-) diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/user/account_role.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/user/account_role.dart index 8c54665e506..43ab75824db 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/user/account_role.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/user/account_role.dart @@ -1,18 +1,18 @@ enum AccountRole { - voter(roleNumber: 0), + /// An account role that is assigned to every account. + /// Allows to vote for proposals. + voter(number: 0), - // TODO(dtscalac): the RBAC specification doesn't define yet the role number - // for the proposer, replace this arbitrary number when it's specified. - proposer(roleNumber: 1), + /// A delegated representative that can vote on behalf of other accounts. + drep(number: 1), - // TODO(dtscalac): the RBAC specification doesn't define yet the role number - // for the drep, replace this arbitrary number when it's specified. - drep(roleNumber: 2); + /// An account role that can create new proposals. + proposer(number: 3); /// The RBAC specified role number. - final int roleNumber; + final int number; - const AccountRole({required this.roleNumber}); + const AccountRole({required this.number}); /// Returns the role which is assigned to every user. static AccountRole get root => voter; diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/crypto/key_derivation.dart b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/crypto/key_derivation.dart index 8832c6f41e8..25baf7f7c3e 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/crypto/key_derivation.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/crypto/key_derivation.dart @@ -3,6 +3,10 @@ import 'package:catalyst_voices_models/catalyst_voices_models.dart'; /// Derives key pairs from a seed phrase. final class KeyDerivation { + /// See: https://github.com/input-output-hk/catalyst-voices/pull/1300 + static const int _purpose = 508; + static const int _type = 139; + static const int _account = 0; // Future Use final CatalystKeyDerivation _keyDerivation; const KeyDerivation(this._keyDerivation); @@ -44,9 +48,7 @@ final class KeyDerivation { /// The path feed into key derivation algorithm /// to generate a key pair from a seed phrase. - /// - // TODO(dtscalac): update when RBAC specifies it String _roleKeyDerivationPath(AccountRole role) { - return "m/${role.roleNumber}'/1234'"; + return "m/$_purpose'/$_type'/$_account'/${role.number}/0"; } } diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/registration/registration_transaction_builder.dart b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/registration/registration_transaction_builder.dart index f273d45e187..173dbeb38cf 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/registration/registration_transaction_builder.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/registration/registration_transaction_builder.dart @@ -82,7 +82,7 @@ final class RegistrationTransactionBuilder { // TODO(dtscalac): when RBAC specification will define other roles // they should be registered here RoleData( - roleNumber: AccountRole.root.roleNumber, + roleNumber: AccountRole.root.number, roleSigningKey: const LocalKeyReference( keyType: LocalKeyReferenceType.x509Certs, offset: 0, diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/test/src/crypto/key_derivation_test.dart b/catalyst_voices/packages/internal/catalyst_voices_services/test/src/crypto/key_derivation_test.dart index 7c9098c8bea..71e6d6a30e5 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/test/src/crypto/key_derivation_test.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/test/src/crypto/key_derivation_test.dart @@ -31,7 +31,7 @@ void main() { for (final role in AccountRole.values) { final keyPair = await keyDerivation.deriveKeyPair( masterKey: masterKey, - path: "m/${role.roleNumber}'/1234'", + path: "m/${role.number}'/1234'", ); expect(keyPair, isNotNull); }