diff --git a/.config/dictionaries/project.dic b/.config/dictionaries/project.dic index 95c8675ac7..273bc761bb 100644 --- a/.config/dictionaries/project.dic +++ b/.config/dictionaries/project.dic @@ -202,3 +202,5 @@ xctest xctestrun xcworkspace yoroi +multiasset +unawaited \ No newline at end of file diff --git a/catalyst_voices/lib/app/view/app_page.dart b/catalyst_voices/lib/app/view/app_page.dart index 121c93f491..503bdfe612 100644 --- a/catalyst_voices/lib/app/view/app_page.dart +++ b/catalyst_voices/lib/app/view/app_page.dart @@ -36,7 +36,17 @@ final class _AppState extends State { } Future _init() async { - await Dependencies.instance.init(); + try { + await Dependencies.instance.init(); + } catch (error, stackTrace) { + // TODO(dtscalac): FutureBuilder that uses this future silences all + // errors, replace it here with proper logging solution. + FlutterError.dumpErrorToConsole( + FlutterErrorDetails(exception: error, stack: stackTrace), + ); + + rethrow; + } } List _multiBlocProviders() { diff --git a/catalyst_voices/pubspec.yaml b/catalyst_voices/pubspec.yaml index 810aaed179..eb8a7ce847 100644 --- a/catalyst_voices/pubspec.yaml +++ b/catalyst_voices/pubspec.yaml @@ -10,8 +10,12 @@ environment: dependencies: animated_text_kit: ^4.2.2 animations: ^2.0.11 + catalyst_cardano: + path: ../catalyst_voices_packages/catalyst_cardano/catalyst_cardano catalyst_cardano_serialization: path: ../catalyst_voices_packages/catalyst_cardano_serialization + catalyst_cardano_web: + path: ../catalyst_voices_packages/catalyst_cardano/catalyst_cardano_web catalyst_voices_assets: path: ./packages/catalyst_voices_assets catalyst_voices_blocs: @@ -30,7 +34,9 @@ dependencies: path: ./packages/catalyst_voices_view_models flutter: sdk: flutter - flutter_adaptive_scaffold: ^0.1.7+2 + # TODO(dtscalac): allow newer versions when we migrate to flutter 3.22.x, + # the package already uses APIs from 3.22.x which are not available in current (3.19.x) flutter + flutter_adaptive_scaffold: 0.1.10+2 flutter_bloc: ^8.1.3 flutter_localized_locales: ^2.0.5 flutter_web_plugins: @@ -46,9 +52,9 @@ dev_dependencies: build_verify: ^3.1.0 catalyst_analysis: git: - url: https://github.com/input-output-hk/catalyst-voices.git - path: catalyst_voices_packages/catalyst_analysis - ref: 3431f7b + url: https://github.com/input-output-hk/catalyst-voices.git + path: catalyst_voices_packages/catalyst_analysis + ref: 3431f7b flutter_test: sdk: flutter go_router_builder: ^2.4.1 diff --git a/catalyst_voices/web/index.html b/catalyst_voices/web/index.html index c062765cef..f5a1895f81 100644 --- a/catalyst_voices/web/index.html +++ b/catalyst_voices/web/index.html @@ -32,6 +32,7 @@ Catalyst Voices + diff --git a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/CHANGELOG.md b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/CHANGELOG.md index bf0e1f93f4..a533b45bb9 100644 --- a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/CHANGELOG.md +++ b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/CHANGELOG.md @@ -1,3 +1,3 @@ -# 1.0.0 +# 0.1.0 * Initial release. diff --git a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/README.md b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/README.md index f6a817cfba..394f8828b3 100644 --- a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/README.md +++ b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/README.md @@ -1 +1,6 @@ # catalyst_cardano + +## TODO(dtscalac): document plugin setup + +* This line is needed in index.html: +* `` diff --git a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/example/.gitignore b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/example/.gitignore new file mode 100644 index 0000000000..29a3a5017f --- /dev/null +++ b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/example/.gitignore @@ -0,0 +1,43 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/example/.metadata b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/example/.metadata new file mode 100644 index 0000000000..bf1f529f46 --- /dev/null +++ b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/example/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "300451adae589accbece3490f4396f10bdf15e6e" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 300451adae589accbece3490f4396f10bdf15e6e + base_revision: 300451adae589accbece3490f4396f10bdf15e6e + - platform: web + create_revision: 300451adae589accbece3490f4396f10bdf15e6e + base_revision: 300451adae589accbece3490f4396f10bdf15e6e + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/example/README.md b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/example/README.md new file mode 100644 index 0000000000..b2a9f4b329 --- /dev/null +++ b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/example/README.md @@ -0,0 +1,3 @@ +# catalyst_cardano_example + +Demonstrates usage of catalyst_cardano plugin diff --git a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/example/analysis_options.yaml b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/example/analysis_options.yaml new file mode 100644 index 0000000000..d5015346bf --- /dev/null +++ b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/example/analysis_options.yaml @@ -0,0 +1,12 @@ +include: package:catalyst_analysis/analysis_options.1.0.0.yaml + +analyzer: + exclude: [ + build/**, + lib/*.g.dart, + lib/generated/** + ] + +linter: + rules: + public_member_api_docs: false diff --git a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/example/lib/main.dart b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/example/lib/main.dart new file mode 100644 index 0000000000..13d31c37cf --- /dev/null +++ b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/example/lib/main.dart @@ -0,0 +1,466 @@ +import 'dart:async'; + +import 'package:catalyst_cardano/catalyst_cardano.dart'; +import 'package:catalyst_cardano_serialization/catalyst_cardano_serialization.dart'; +import 'package:cbor/cbor.dart'; +import 'package:convert/convert.dart'; +import 'package:flutter/material.dart'; + +void main() { + runApp(const MyApp()); +} + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'catalyst_cardano_example', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), + useMaterial3: true, + ), + home: const MyHomePage(), + ); + } +} + +class MyHomePage extends StatefulWidget { + const MyHomePage({super.key}); + + @override + State createState() => _MyHomePageState(); +} + +class _MyHomePageState extends State { + bool _isLoading = true; + Object? _error; + List? _wallets; + CardanoWalletApi? _api; + + @override + void initState() { + super.initState(); + + unawaited(_loadWallets()); + } + + @override + Widget build(BuildContext context) { + final Widget child; + + if (_isLoading) { + child = const _Loader(); + } else if (_error != null) { + child = _Error(error: _error); + } else if (_api != null) { + child = _WalletDetails(api: _api!); + } else if (_wallets != null) { + child = _wallets!.isEmpty + ? const _EmptyWallets() + : _WalletChooser( + wallets: _wallets!, + onEnable: _onEnableWallet, + ); + } else { + child = const _Loader(); + } + + return Scaffold( + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: SelectionArea( + child: child, + ), + ), + ); + } + + Future _loadWallets() async { + try { + setState(() => _isLoading = true); + final wallets = await CatalystCardano.instance.getWallets(); + setState(() => _wallets = wallets); + } catch (error) { + setState(() => _error = error); + } finally { + setState(() => _isLoading = false); + } + } + + Future _onEnableWallet(CardanoWallet wallet) async { + try { + setState(() => _isLoading = true); + final api = await wallet.enable(); + setState(() => _api = api); + } catch (error) { + setState(() => _error = error); + } finally { + setState(() => _isLoading = false); + } + } +} + +class _Error extends StatelessWidget { + final Object? error; + + const _Error({required this.error}); + + @override + Widget build(BuildContext context) { + return Center( + child: Text(error.toString()), + ); + } +} + +class _Loader extends StatelessWidget { + const _Loader(); + + @override + Widget build(BuildContext context) { + return const Center( + child: CircularProgressIndicator(), + ); + } +} + +class _EmptyWallets extends StatelessWidget { + const _EmptyWallets(); + + @override + Widget build(BuildContext context) { + return const Center( + child: Text( + 'There are no active wallet extensions', + ), + ); + } +} + +class _WalletChooser extends StatelessWidget { + final List wallets; + final ValueChanged onEnable; + + const _WalletChooser({ + required this.wallets, + required this.onEnable, + }); + + @override + Widget build(BuildContext context) { + return ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: wallets.length, + itemBuilder: (context, index) { + final wallet = wallets[index]; + return _WalletItem( + wallet: wallet, + onEnable: () => onEnable(wallet), + ); + }, + separatorBuilder: (context, index) => const SizedBox(height: 16), + ); + } +} + +class _WalletItem extends StatelessWidget { + final CardanoWallet wallet; + final VoidCallback onEnable; + + const _WalletItem({ + required this.wallet, + required this.onEnable, + }); + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Image.network( + wallet.icon, + width: 64, + height: 64, + ), + Text('Name: ${wallet.name}'), + Text('Api version: ${wallet.apiVersion}'), + Text( + 'Supported extensions: ' + '${_formatExtensions(wallet.supportedExtensions)}', + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: onEnable, + child: const Text('Enable wallet'), + ), + ], + ), + ), + ); + } +} + +class _WalletDetails extends StatefulWidget { + final CardanoWalletApi api; + + const _WalletDetails({required this.api}); + + @override + State<_WalletDetails> createState() => _WalletDetailsState(); +} + +class _WalletDetailsState extends State<_WalletDetails> { + Coin? _balance; + List? _extensions; + NetworkId? _networkId; + ShelleyAddress? _changeAddress; + List? _rewardAddresses; + List? _unusedAddresses; + List? _usedAddresses; + List? _utxos; + + @override + void initState() { + super.initState(); + unawaited(_loadData()); + } + + @override + void didUpdateWidget(_WalletDetails oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.api != widget.api) { + unawaited(_loadData()); + } + } + + Future _loadData() async { + try { + final balance = await widget.api.getBalance(); + final extensions = await widget.api.getExtensions(); + final networkId = await widget.api.getNetworkId(); + final changeAddress = await widget.api.getChangeAddress(); + final rewardAddresses = await widget.api.getRewardAddresses(); + final unusedAddresses = await widget.api.getUnusedAddresses(); + final usedAddresses = await widget.api.getUsedAddresses(); + final utxos = await widget.api.getUtxos(); + + if (mounted) { + setState(() { + _balance = balance; + _extensions = extensions; + _networkId = networkId; + _changeAddress = changeAddress; + _rewardAddresses = rewardAddresses; + _unusedAddresses = unusedAddresses; + _usedAddresses = usedAddresses; + _utxos = utxos; + }); + } + } catch (error) { + await _showDialog( + title: 'Load data', + message: 'Error: $error', + ); + } + } + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('Balance: ${_balance ?? '---'}\n'), + Text('Extensions: ${_formatExtensions(_extensions)}\n'), + Text('Network ID: $_networkId\n'), + Text('Change address:\n${_changeAddress?.toBech32() ?? '---'}\n'), + Text( + 'Reward addresses:\n${_formatAddresses(_rewardAddresses)}\n', + ), + Text( + 'Unused addresses:\n${_formatAddresses(_unusedAddresses)}\n', + ), + Text('Used addresses:\n${_formatAddresses(_usedAddresses)}\n'), + Text('UTXOs:\n${_formatUtxos(_utxos)}\n'), + Row( + children: [ + ElevatedButton( + onPressed: _signData, + child: const Text('Sign data'), + ), + const SizedBox(width: 16), + ElevatedButton( + onPressed: _signAndSubmitTx, + child: const Text('Sign & submit tx'), + ), + ], + ), + ], + ), + ), + ), + ); + } + + Future _signData() async { + var result = ''; + + try { + final rewardAddress = await widget.api.getRewardAddresses(); + final signer = await widget.api.signData( + address: rewardAddress.first, + payload: [1, 2, 3], + ); + + result = 'Signature: ${hex.encode(cbor.encode(signer.toCbor()))}'; + } catch (error) { + result = error.toString(); + } + + await _showDialog( + title: 'Sign data', + message: result, + ); + } + + Future _signAndSubmitTx() async { + var result = ''; + try { + final api = widget.api; + final changeAddress = await api.getChangeAddress(); + + final utxos = await api.getUtxos( + amount: const Coin(1000000), + ); + + final unsignedTx = _buildUnsignedTx( + utxos: utxos, + changeAddress: changeAddress, + ); + + final witnessSet = await api.signTx(transaction: unsignedTx); + + final signedTx = Transaction( + body: unsignedTx.body, + isValid: true, + witnessSet: witnessSet, + ); + + final txHash = await api.submitTx(transaction: signedTx); + result = 'Tx hash: ${txHash.toHex()}'; + } catch (error) { + result = error.toString(); + } + + await _showDialog( + title: 'Sign & submit tx', + message: result, + ); + } + + Future _showDialog({ + required String title, + required String message, + }) async { + if (mounted) { + await showDialog( + context: context, + builder: (context) => SelectionArea( + child: AlertDialog( + title: Text(title), + content: Text(message), + actions: [ + ElevatedButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ), + ), + ); + } + } +} + +String _formatExtensions(List? extensions) { + if (extensions == null) { + return '---'; + } + + return extensions.map((e) => 'cip-${e.cip}').join(', '); +} + +String _formatAddresses(List? addresses) { + if (addresses == null) { + return '---'; + } + + return addresses.map((e) => e.toBech32()).join('\n'); +} + +String _formatUtxos(List? utxos) { + if (utxos == null) { + return '---'; + } + + return utxos.map(_formatUtxo).join('\n'); +} + +String _formatUtxo(TransactionUnspentOutput utxo) { + return 'Tx: ${utxo.input.transactionId}' + '\nIndex: ${utxo.input.index}' + '\nAmount: ${utxo.output.amount}\n'; +} + +Transaction _buildUnsignedTx({ + required List utxos, + required ShelleyAddress changeAddress, +}) { + const txBuilderConfig = TransactionBuilderConfig( + feeAlgo: LinearFee( + constant: Coin(155381), + coefficient: Coin(44), + ), + maxTxSize: 8000, + coinsPerUtxoByte: Coin(4310), + ); + + /* cSpell:disable */ + final preprodFaucetAddress = ShelleyAddress.fromBech32( + 'addr_test1vzpwq95z3xyum8vqndgdd9mdnmafh3djcxnc6jemlgdmswcve6tkw', + ); + /* cSpell:enable */ + + final txOutput = TransactionOutput( + address: preprodFaucetAddress, + amount: const Coin(1000000), + ); + + final txBuilder = TransactionBuilder( + config: txBuilderConfig, + inputs: utxos, + networkId: NetworkId.testnet, + ); + + final txBody = txBuilder + .withOutput(txOutput) + .withChangeAddressIfNeeded(changeAddress) + .buildBody(); + + return Transaction( + body: txBody, + isValid: true, + witnessSet: const TransactionWitnessSet(vkeyWitnesses: {}), + ); +} diff --git a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/example/pubspec.yaml b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/example/pubspec.yaml new file mode 100644 index 0000000000..15131bd376 --- /dev/null +++ b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/example/pubspec.yaml @@ -0,0 +1,31 @@ +name: catalyst_cardano_example +description: Demonstrates usage of catalyst_cardano plugin. +publish_to: "none" + +version: 1.0.0+1 + +environment: + sdk: ">=3.3.0 <4.0.0" + flutter: 3.19.5 + +dependencies: + catalyst_cardano: + path: "../" + catalyst_cardano_serialization: + path: "../" + catalyst_cardano_web: + path: "../../catalyst_cardano_web" + cbor: ^6.2.0 + convert: ^3.1.1 + cupertino_icons: ^1.0.6 + flutter: + sdk: flutter + +dev_dependencies: + catalyst_analysis: + path: ../../../catalyst_analysis + flutter_test: + sdk: flutter + +flutter: + uses-material-design: true diff --git a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/example/web/favicon.png b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/example/web/favicon.png new file mode 100644 index 0000000000..8aaa46ac1a Binary files /dev/null and b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/example/web/favicon.png differ diff --git a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/example/web/icons/Icon-192.png b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/example/web/icons/Icon-192.png new file mode 100644 index 0000000000..b749bfef07 Binary files /dev/null and b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/example/web/icons/Icon-192.png differ diff --git a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/example/web/icons/Icon-512.png b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/example/web/icons/Icon-512.png new file mode 100644 index 0000000000..88cfd48dff Binary files /dev/null and b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/example/web/icons/Icon-512.png differ diff --git a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/example/web/icons/Icon-maskable-192.png b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/example/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000000..eb9b4d76e5 Binary files /dev/null and b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/example/web/icons/Icon-maskable-192.png differ diff --git a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/example/web/icons/Icon-maskable-512.png b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/example/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000000..d69c56691f Binary files /dev/null and b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/example/web/icons/Icon-maskable-512.png differ diff --git a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/example/web/index.html b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/example/web/index.html new file mode 100644 index 0000000000..58e57287c7 --- /dev/null +++ b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/example/web/index.html @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + example + + + + + + + + + + + + + \ No newline at end of file diff --git a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/example/web/manifest.json b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/example/web/manifest.json new file mode 100644 index 0000000000..e9681d4bbf --- /dev/null +++ b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/example/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "example", + "short_name": "example", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "Demonstrates usage of catalyst_cardano plugin", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/lib/catalyst_cardano.dart b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/lib/catalyst_cardano.dart index 9de4f0fdf4..fc02e74de4 100644 --- a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/lib/catalyst_cardano.dart +++ b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/lib/catalyst_cardano.dart @@ -1 +1,3 @@ +export 'package:catalyst_cardano_platform_interface/catalyst_cardano_platform_interface.dart'; + export 'src/catalyst_cardano.dart'; diff --git a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/lib/src/catalyst_cardano.dart b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/lib/src/catalyst_cardano.dart index 72dfd12173..1876de7499 100644 --- a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/lib/src/catalyst_cardano.dart +++ b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/lib/src/catalyst_cardano.dart @@ -1,8 +1,16 @@ import 'package:catalyst_cardano_platform_interface/catalyst_cardano_platform_interface.dart'; -/// Prints hello message, function to be removed later by providing actual -/// implementations of methods related to cardano multiplatform lib. -Future printCatalystCardanoHello() async { - // ignore: avoid_print, will be replaced later by actual implementation - await CatalystCardanoPlatform.instance.printHello(); +/// A Flutter plugin exposing the [CIP-30](https://cips.cardano.org/cip/CIP-30) +/// and [CIP-95](https://cips.cardano.org/cip/CIP-95) APIs. +class CatalystCardano { + /// The default instance of [CatalystCardano] to use. + static final CatalystCardano instance = CatalystCardano._(); + + CatalystCardano._(); + + /// Returns available wallet extensions exposed under + /// cardano.{walletName} according to CIP-30 standard. + Future> getWallets() async { + return CatalystCardanoPlatform.instance.getWallets(); + } } diff --git a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/pubspec.yaml b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/pubspec.yaml index 5d999a062a..8cabcd25c8 100644 --- a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/pubspec.yaml +++ b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/pubspec.yaml @@ -1,9 +1,9 @@ name: catalyst_cardano -description: Flutter plugin wrapping the Cardano Multiplatform Lib Rust library. +description: A Flutter plugin exposing the CIP-30 and CIP-95 APIs. repository: https://github.com/input-output-hk/catalyst-voices/tree/main/catalyst_voices_packages/catalyst_cardano/catalyst_cardano issue_tracker: https://github.com/input-output-hk/catalyst-voices/issues topics: [blockchain, cardano, cryptocurrency, wallet] -version: 1.0.0 +version: 0.1.0 environment: sdk: ">=3.3.0 <4.0.0" @@ -16,8 +16,9 @@ flutter: default_package: catalyst_cardano_web dependencies: - catalyst_cardano_platform_interface: ^1.0.0 - catalyst_cardano_web: ^1.0.0 + catalyst_cardano_platform_interface: ^0.1.0 + catalyst_cardano_serialization: ^0.1.0 + catalyst_cardano_web: ^0.1.0 flutter: sdk: flutter diff --git a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_platform_interface/CHANGELOG.md b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_platform_interface/CHANGELOG.md index bf0e1f93f4..a533b45bb9 100644 --- a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_platform_interface/CHANGELOG.md +++ b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_platform_interface/CHANGELOG.md @@ -1,3 +1,3 @@ -# 1.0.0 +# 0.1.0 * Initial release. diff --git a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_platform_interface/lib/catalyst_cardano_platform_interface.dart b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_platform_interface/lib/catalyst_cardano_platform_interface.dart index 256466b957..9c08dbe7a5 100644 --- a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_platform_interface/lib/catalyst_cardano_platform_interface.dart +++ b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_platform_interface/lib/catalyst_cardano_platform_interface.dart @@ -1 +1,3 @@ +export 'src/cardano_wallet.dart'; export 'src/catalyst_cardano_platform.dart'; +export 'src/exceptions.dart'; diff --git a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_platform_interface/lib/src/cardano_wallet.dart b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_platform_interface/lib/src/cardano_wallet.dart new file mode 100644 index 0000000000..e56cfa8105 --- /dev/null +++ b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_platform_interface/lib/src/cardano_wallet.dart @@ -0,0 +1,192 @@ +import 'package:catalyst_cardano_platform_interface/src/exceptions.dart'; +import 'package:catalyst_cardano_serialization/catalyst_cardano_serialization.dart'; + +/// A cardano wallet extension that has been injected +/// into the browser's cardano.{walletName} object. +/// +/// There might be multiple available wallet extensions but typically +/// the user will interact with only one extension at a time. +/// +/// Use [name] and [icon] to display a list of extensions to the user +/// and call [enable] after the user decides with which wallet they want +/// to interact. +abstract interface class CardanoWallet { + /// A name for the wallet which can be used inside of the dApp + /// for the purpose of asking the user which wallet they would like + /// to connect with. + String get name; + + /// A URI image (e.g. data URI base64 or other) for img src for the wallet + /// which can be used inside of the dApp for the purpose of asking the user + /// which wallet they would like to connect with. + String get icon; + + /// The version number of the API that the wallet supports. + String get apiVersion; + + /// A list of extensions supported by the wallet. + /// + /// Extensions may be requested by dApps on initialization. + /// Some extensions may be mutually conflicting and this list does not + /// thereby reflect what extensions will be enabled by the wallet. + /// Yet it informs on what extensions are known and can be + /// requested by dApps if needed. + List get supportedExtensions; + + /// Returns true if the dApp is already connected to the user's wallet, + /// or if requesting access would return true without user confirmation + /// (e.g. the dApp is whitelisted), and false otherwise. + /// + /// If this function returns true, then any subsequent calls to + /// wallet.enable() during the current session should succeed + /// and return the API object. + Future isEnabled(); + + /// This is the entrypoint to start communication with the user's wallet. + /// + /// The wallet should request the user's permission to connect the web page + /// to the user's wallet, and if permission has been granted, the full API + /// will be returned to the dApp to use. The wallet can choose to maintain + /// a whitelist to not necessarily ask the user's permission every time access + /// is requested, but this behavior is up to the wallet and should be + /// transparent to web pages using this API. If a wallet is already connected + /// this function should not request access a second time, and instead just + /// return the API object. + Future enable({List? extensions}); +} + +/// The full API of enabled wallet extension. +abstract interface class CardanoWalletApi { + /// Returns the total balance available of the wallet. + /// + /// This is the same as summing the results of [getUtxos], + /// but it is both useful to dApps and likely already maintained by the + /// implementing wallet in a more efficient manner so it has been included + /// in the API as well. + Future getBalance(); + + /// Retrieves the list of extensions enabled by the wallet. + /// + /// This may be influenced by the set of extensions requested + /// in the initial enable request. + Future> getExtensions(); + + /// Returns the network id of the currently connected account. + /// + /// 0 is testnet and 1 is mainnet but other networks can possibly + /// be returned by wallets. + /// + /// Those other network ID values are not governed by this document. + /// This result will stay the same unless the connected account has changed. + Future getNetworkId(); + + /// Returns an address owned by the wallet that should be used + /// as a change address to return leftover assets during transaction + /// creation back to the connected wallet. + /// + /// This can be used as a generic receive address as well. + Future getChangeAddress(); + + /// Returns the reward addresses owned by the wallet. + /// + /// This can return multiple addresses e.g. CIP-0018. + Future> getRewardAddresses(); + + /// Returns a list of unused addresses controlled by the wallet. + Future> getUnusedAddresses(); + + /// Returns a list of all used (included in some on-chain transaction) + /// addresses controlled by the wallet. + /// + /// The results can be further paginated by [paginate] if it is not undefined + Future> getUsedAddresses({Paginate? paginate}); + + /// If [amount] is null, this shall return a list of all UTXOs + /// (unspent transaction outputs) controlled by the wallet. + /// + /// If [amount] is not null, this request shall be limited to just the UTXOs + /// that are required to reach the combined ADA/multiasset value target + /// specified in amount, and if this cannot be attained, + /// null shall be returned. The results can be further paginated by + /// [paginate] if it is not null. + Future> getUtxos({ + Coin? amount, + Paginate? paginate, + }); + + /// This endpoint utilizes the CIP-0008 signing spec for + /// standardization/safety reasons. It allows the dApp to request the user + /// to sign a payload conforming to said spec. The user's consent should be + /// requested and the message to sign shown to the user. The payment key + /// from [address] will be used for base, enterprise and pointer addresses to + /// determine the EdDSA25519 key used. The staking key will be used for + /// reward addresses. + /// + /// Throws [WalletDataSignException]. + Future signData({ + required ShelleyAddress address, + required List payload, + }); + + /// Requests that a user sign the unsigned portions + /// of the supplied transaction. + /// + /// The wallet should ask the user for permission, and if given, try to sign + /// the supplied body and return a signed transaction. If [partialSign] is + /// true, the wallet only tries to sign what it can. If [partialSign] is false + /// and the wallet could not sign the entire transaction, [TxSignException] + /// shall be returned with the ProofGeneration code. + /// + /// Likewise if the user declined in either case it shall return the + /// UserDeclined code. Only the portions of the witness set that were signed + /// as a result of this call are returned to encourage dApps to verify the + /// contents returned by this endpoint while building the final transaction. + Future signTx({ + required Transaction transaction, + bool partialSign = false, + }); + + /// As wallets should already have this ability, we allow dApps to request + /// that a transaction be sent through it. + /// + /// If the wallet accepts the transaction and tries to send it, + /// it shall return the transaction id for the dApp to track. + /// + /// The wallet is free to return the [TxSendException] with code Refused + /// if they do not wish to send it, or Failure if there was an error + /// in sending it (e.g. preliminary checks failed on signatures). + Future submitTx({ + required Transaction transaction, + }); +} + +/// Defines the [cip] extension version. +final class CipExtension { + /// The version of the CIP extension. + final int cip; + + /// The default constructor for [CipExtension]. + const CipExtension({ + required this.cip, + }); +} + +/// Defines the pagination constraints when querying data. +/// +/// Instead of fetching the whole data-set at once, +/// the data can be queried in batches. +final class Paginate { + /// The batch index. + /// + /// Starts counting from 0. + final int page; + + /// The batch size per [page]. + final int limit; + + /// The default constructor for [Paginate]. + const Paginate({ + required this.page, + required this.limit, + }); +} diff --git a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_platform_interface/lib/src/catalyst_cardano_platform.dart b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_platform_interface/lib/src/catalyst_cardano_platform.dart index de17fbcb47..e589bc77e6 100644 --- a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_platform_interface/lib/src/catalyst_cardano_platform.dart +++ b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_platform_interface/lib/src/catalyst_cardano_platform.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:catalyst_cardano_platform_interface/src/cardano_wallet.dart'; import 'package:plugin_platform_interface/plugin_platform_interface.dart'; /// The interface that implementations of catalyst_cardano must @@ -39,9 +40,9 @@ abstract class CatalystCardanoPlatform extends PlatformInterface { /// Constructs a CatalystCardanoPlatform. CatalystCardanoPlatform() : super(token: _token); - /// Temporary method which will be replaced by actual implementation of - /// cardano multiplatform lib. - Future printHello() { - throw UnimplementedError('printHello() has not been implemented.'); + /// Returns available wallet extensions exposed under + /// cardano.{walletName} according to CIP-30 standard. + Future> getWallets() { + throw UnimplementedError('getWallets() has not been implemented.'); } } diff --git a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_platform_interface/lib/src/exceptions.dart b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_platform_interface/lib/src/exceptions.dart new file mode 100644 index 0000000000..70e7510837 --- /dev/null +++ b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_platform_interface/lib/src/exceptions.dart @@ -0,0 +1,159 @@ +import 'package:catalyst_cardano_platform_interface/catalyst_cardano_platform_interface.dart'; + +/// Defines a set of possible exceptions that might occur when +/// interacting with the wallet extension api. +final class WalletApiException implements Exception { + /// A more specific failure reason. + final WalletApiErrorCode code; + + /// The human readable info about the exception. + final String info; + + /// The default constructor for [WalletApiException]. + const WalletApiException({ + required this.code, + required this.info, + }); + + @override + String toString() => 'WalletApiException(code=$code,info=$info)'; +} + +/// A specific error code related to the [WalletApiException]. +enum WalletApiErrorCode { + /// Inputs do not conform to this spec or are otherwise invalid. + invalidRequest(tag: -1), + + /// An error occurred during execution of this API call. + internalError(tag: -2), + + /// The request was refused due to lack of access - e.g. wallet disconnects. + refused(tag: -3), + + /// The account has changed. The dApp should call wallet.enable() + /// to reestablish connection to the new account. The wallet should not ask + /// for confirmation as the user was the one who initiated the account change + /// in the first place. + accountChange(tag: -4); + + /// The error code number. + final int tag; + + const WalletApiErrorCode({required this.tag}); +} + +/// Defines a set of possible exceptions that might occur when +/// calling the [CardanoWalletApi.signData] method. +final class WalletDataSignException implements Exception { + /// A more specific failure reason. + final WalletDataSignErrorCode code; + + /// The human readable info about the exception. + final String info; + + /// The default constructor for [WalletDataSignException]. + const WalletDataSignException({ + required this.code, + required this.info, + }); + + @override + String toString() => 'WalletDataSignException(code=$code,info=$info)'; +} + +/// A specific error code related to the [WalletDataSignException]. +enum WalletDataSignErrorCode { + /// Wallet could not sign the data (e.g. does not have the secret key + /// associated with the address). + proofGeneration(tag: 1), + + /// Address was not a P2PK address and thus had no SK associated with it. + addressNotPk(tag: 2), + + /// User declined to sign the data + userDeclined(tag: 3); + + /// The error code number. + final int tag; + + const WalletDataSignErrorCode({required this.tag}); +} + +/// [maxSize] is the maximum size for pagination and if the dApp +/// tries to request pages outside of this boundary this error is thrown. +final class WalletPaginateException implements Exception { + /// The maximum allowed value of the [Paginate.page]. + final int maxSize; + + /// The default constructor for [WalletPaginateException]. + const WalletPaginateException({required this.maxSize}); + + @override + String toString() => 'WalletPaginateException(maxSize=$maxSize)'; +} + +/// Exception thrown when signing the transaction fails. +final class TxSignException implements Exception { + /// A more specific failure reason. + final TxSignErrorCode code; + + /// The human readable info about the exception. + final String info; + + /// The default constructor for [TxSignException]. + const TxSignException({ + required this.code, + required this.info, + }); + + @override + String toString() => 'TxSignException(code=$code,info=$info)'; +} + +/// A specific error code related to the [TxSignException]. +enum TxSignErrorCode { + /// User has accepted the transaction sign, + /// but the wallet was unable to sign the transaction + /// (e.g. not having some of the private keys). + proofGeneration(tag: 1), + + /// User declined to sign the transaction. + userDeclined(tag: 2); + + /// The error code number. + final int tag; + + const TxSignErrorCode({required this.tag}); +} + +/// Exception thrown when submitting the transaction fails. +final class TxSendException implements Exception { + /// A more specific failure reason. + final TxSendErrorCode code; + + /// The human readable info about the exception. + final String info; + + /// The default constructor for [TxSendException]. + const TxSendException({ + required this.code, + required this.info, + }); + + @override + String toString() => 'TxSendException(code=$code,info=$info)'; +} + +/// A specific error code related to the [TxSendException]. +enum TxSendErrorCode { + /// Wallet refuses to send the tx (could be rate limiting). + refused(tag: 1), + + /// Wallet could not send the tx. + failure(tag: 2); + + /// The error code number. + final int tag; + + const TxSendErrorCode({required this.tag}); +} diff --git a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_platform_interface/pubspec.yaml b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_platform_interface/pubspec.yaml index ba11aa96e9..f8ff857959 100644 --- a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_platform_interface/pubspec.yaml +++ b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_platform_interface/pubspec.yaml @@ -3,13 +3,14 @@ description: A common platform interface for the catalyst_cardano plugin. repository: https://github.com/input-output-hk/catalyst-voices/tree/main/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_platform_interface issue_tracker: https://github.com/input-output-hk/catalyst-voices/issues topics: [blockchain, cardano, cryptocurrency, wallet] -version: 1.0.0 +version: 0.1.0 environment: sdk: ">=3.3.0 <4.0.0" flutter: 3.19.5 dependencies: + catalyst_cardano_serialization: ^0.1.0 flutter: sdk: flutter plugin_platform_interface: ^2.1.7 diff --git a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_web/CHANGELOG.md b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_web/CHANGELOG.md index bf0e1f93f4..a533b45bb9 100644 --- a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_web/CHANGELOG.md +++ b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_web/CHANGELOG.md @@ -1,3 +1,3 @@ -# 1.0.0 +# 0.1.0 * Initial release. diff --git a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_web/assets/js/catalyst_cardano.js b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_web/assets/js/catalyst_cardano.js new file mode 100644 index 0000000000..74e4df97db --- /dev/null +++ b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_web/assets/js/catalyst_cardano.js @@ -0,0 +1,23 @@ +// Returns available wallet extensions exposed under +// cardano.{walletName} according to CIP-30 standard. +function _getWallets() { + const cardano = window.cardano; + if (cardano) { + const keys = Object.keys(window.cardano); + const possibleWallets = keys.map((k) => cardano[k]); + return possibleWallets.filter((w) => typeof w === "object" && "enable" in w); + } + + return []; +} + +// A namespace containing the JS functions that +// can be executed from dart side +const catalyst_cardano = { + getWallets: _getWallets, +} + +// Expose cardano multiplatform as globally accessible +// so that we can call it via catalyst_cardano.function_name() from +// other scripts or dart without needing to care about module imports +window.catalyst_cardano = catalyst_cardano; \ No newline at end of file diff --git a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_web/lib/catalyst_cardano_web.dart b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_web/lib/catalyst_cardano_web.dart index b152d1c504..23533d2b4a 100644 --- a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_web/lib/catalyst_cardano_web.dart +++ b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_web/lib/catalyst_cardano_web.dart @@ -1,26 +1,29 @@ import 'dart:async'; +import 'dart:js_interop'; import 'package:catalyst_cardano_platform_interface/catalyst_cardano_platform_interface.dart'; +import 'package:catalyst_cardano_web/src/interop/catalyst_cardano_interop.dart' + as interop; import 'package:flutter_web_plugins/flutter_web_plugins.dart' show Registrar; /// The web implementation of [CatalystCardanoPlatform]. /// /// This class implements the `package:catalyst_cardano` functionality /// for the web. -class CatalystCardanoPlugin extends CatalystCardanoPlatform { +class CatalystCardanoWeb extends CatalystCardanoPlatform { /// A constructor that allows tests to override the window object used by the /// plugin. - CatalystCardanoPlugin(); + CatalystCardanoWeb(); @override - Future printHello() async { - // ignore: avoid_print - print('hello world from cardano multiplatform'); + Future> getWallets() async { + final wallets = interop.getWallets().toDart; + return wallets.map((e) => e.toDart).toList(); } /// Registers this class as the default instance of /// [CatalystCardanoPlatform]. static void registerWith(Registrar registrar) { - CatalystCardanoPlatform.instance = CatalystCardanoPlugin(); + CatalystCardanoPlatform.instance = CatalystCardanoWeb(); } } diff --git a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_web/lib/src/interop/catalyst_cardano_interop.dart b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_web/lib/src/interop/catalyst_cardano_interop.dart new file mode 100644 index 0000000000..13b6be0e03 --- /dev/null +++ b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_web/lib/src/interop/catalyst_cardano_interop.dart @@ -0,0 +1,145 @@ +@JS('catalyst_cardano') +library catalyst_cardano_interop; + +import 'dart:js_interop'; + +import 'package:catalyst_cardano_platform_interface/catalyst_cardano_platform_interface.dart'; +import 'package:catalyst_cardano_web/src/interop/catalyst_cardano_wallet_proxy.dart'; + +/// Lists all injected Cardano wallet extensions that are reachable +/// via window.cardano.{walletName} in javascript. +@JS() +external JSArray getWallets(); + +/// The JS representation of the [CardanoWallet]. +extension type JSCardanoWallet(JSObject _) implements JSObject { + /// See [CardanoWallet.name]. + external JSString get name; + + /// See [CardanoWallet.icon]. + external JSString get icon; + + /// See [CardanoWallet.apiVersion]. + external JSString get apiVersion; + + /// See [CardanoWallet.supportedExtensions]. + external JSArray get supportedExtensions; + + /// See [CardanoWallet.isEnabled]. + external JSPromise isEnabled(); + + /// See [CardanoWallet.enable]. + external JSPromise enable([ + JSCipExtensions? extensions, + ]); + + /// Converts JS representation to pure dart representation. + CardanoWallet get toDart => JSCardanoWalletProxy(this); +} + +/// The JS representation of the [CardanoWalletApi]. +extension type JSCardanoWalletApi(JSObject _) implements JSObject { + /// See [CardanoWalletApi.getBalance]. + external JSPromise getBalance(); + + /// See [CardanoWalletApi.getExtensions]. + external JSPromise> getExtensions(); + + /// See [CardanoWalletApi.getNetworkId]. + external JSPromise getNetworkId(); + + /// See [CardanoWalletApi.getChangeAddress]. + external JSPromise getChangeAddress(); + + /// See [CardanoWalletApi.getRewardAddresses]. + external JSPromise> getRewardAddresses(); + + /// See [CardanoWalletApi.getUnusedAddresses]. + external JSPromise> getUnusedAddresses(); + + /// See [CardanoWalletApi.getUsedAddresses]. + external JSPromise> getUsedAddresses([ + JSPaginate? paginate, + ]); + + /// See [CardanoWalletApi.getUtxos]. + external JSPromise> getUtxos([ + JSNumber? amount, + JSPaginate? paginate, + ]); + + /// See [CardanoWalletApi.signData]. + external JSPromise signData( + JSString address, + JSString payload, + ); + + /// See [CardanoWalletApi.signTx]. + external JSPromise signTx( + JSString tx, [ + JSBoolean? partialSign, + ]); + + /// See [CardanoWalletApi.submitTx]. + external JSPromise submitTx(JSString tx); + + /// Converts JS representation to pure dart representation. + CardanoWalletApi get toDart => JSCardanoWalletApiProxy(this); +} + +/// Represents wallet extensions to be activated in [JSCardanoWallet.enable]. +extension type JSCipExtensions._(JSObject _) implements JSObject { + /// An array of extensions. + external JSArray get extensions; + + /// The default constructor for [JSCipExtensions]. + external factory JSCipExtensions({JSArray extensions}); + + /// Constructs [JSCipExtensions] from dart representation. + factory JSCipExtensions.fromDart(List extensions) { + return JSCipExtensions( + extensions: extensions.map(JSCipExtension.fromDart).toList().toJS, + ); + } + + /// Converts JS representation to pure dart representation. + List get toDart => + extensions.toDart.map((e) => e.toDart).toList(); +} + +/// The JS representation of the [CipExtension]. +extension type JSCipExtension._(JSObject _) implements JSObject { + /// See [JSCipExtension.cip]. + external JSNumber get cip; + + /// The default constructor for [JSCipExtension]. + external factory JSCipExtension({JSNumber cip}); + + /// Constructs [JSCipExtension] from dart representation. + factory JSCipExtension.fromDart(CipExtension extension) { + return JSCipExtension(cip: extension.cip.toJS); + } + + /// Converts JS representation to pure dart representation. + CipExtension get toDart => CipExtension(cip: cip.toDartInt); +} + +/// The JS representation of the [Paginate]. +extension type JSPaginate._(JSObject _) implements JSObject { + /// The default constructor for [JSPaginate]. + external factory JSPaginate({JSNumber page, JSNumber limit}); + + /// Constructs [JSPaginate] from dart representation. + factory JSPaginate.fromDart(Paginate paginate) { + return JSPaginate( + page: paginate.page.toJS, + limit: paginate.limit.toJS, + ); + } + + /// See [JSPaginate.page]. + external JSNumber get page; + + /// See [JSPaginate.limit]. + external JSNumber get limit; +} diff --git a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_web/lib/src/interop/catalyst_cardano_wallet_proxy.dart b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_web/lib/src/interop/catalyst_cardano_wallet_proxy.dart new file mode 100644 index 0000000000..ab8fdaa35e --- /dev/null +++ b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_web/lib/src/interop/catalyst_cardano_wallet_proxy.dart @@ -0,0 +1,298 @@ +import 'dart:js_interop'; + +import 'package:catalyst_cardano_platform_interface/catalyst_cardano_platform_interface.dart'; +import 'package:catalyst_cardano_serialization/catalyst_cardano_serialization.dart'; +import 'package:catalyst_cardano_web/src/interop/catalyst_cardano_interop.dart'; +import 'package:cbor/cbor.dart'; +import 'package:convert/convert.dart'; + +/// A wrapper around [JSCardanoWallet] that translates between JS/dart layers. +class JSCardanoWalletProxy implements CardanoWallet { + final JSCardanoWallet _delegate; + + /// The default constructor for [JSCardanoWalletProxy]. + JSCardanoWalletProxy(this._delegate); + + @override + String get name => _delegate.name.toDart; + + @override + String get icon => _delegate.icon.toDart; + + @override + String get apiVersion => _delegate.apiVersion.toDart; + + @override + List get supportedExtensions => + _delegate.supportedExtensions.toDart.map((e) => e.toDart).toList(); + + @override + Future isEnabled() async { + try { + return await _delegate.isEnabled().toDart.then((e) => e.toDart); + } catch (ex) { + throw _mapApiException(ex) ?? _fallbackApiException(ex); + } + } + + @override + Future enable({List? extensions}) async { + try { + final jsExtensions = + extensions != null ? JSCipExtensions.fromDart(extensions) : null; + + return await _delegate.enable(jsExtensions).toDart.then((e) => e.toDart); + } catch (ex) { + throw _mapApiException(ex) ?? _fallbackApiException(ex); + } + } +} + +/// A wrapper around [JSCardanoWalletApi] that translates between JS/dart layers. +class JSCardanoWalletApiProxy implements CardanoWalletApi { + final JSCardanoWalletApi _delegate; + + /// The default constructor for [JSCardanoWalletApiProxy]. + JSCardanoWalletApiProxy(this._delegate); + + @override + Future getBalance() async { + try { + final result = await _delegate.getBalance().toDart.then((e) => e.toDart); + return Coin.fromCbor(cbor.decode(hex.decode(result))); + } catch (ex) { + throw _mapApiException(ex) ?? _fallbackApiException(ex); + } + } + + @override + Future> getExtensions() async { + try { + return await _delegate + .getExtensions() + .toDart + .then((array) => array.toDart.map((item) => item.toDart).toList()); + } catch (ex) { + throw _mapApiException(ex) ?? _fallbackApiException(ex); + } + } + + @override + Future getNetworkId() async { + try { + final result = + await _delegate.getNetworkId().toDart.then((e) => e.toDartInt); + return NetworkId.fromId(result); + } catch (ex) { + throw _mapApiException(ex) ?? _fallbackApiException(ex); + } + } + + @override + Future getChangeAddress() async { + try { + return await _delegate + .getChangeAddress() + .toDart + .then((e) => ShelleyAddress(hex.decode(e.toDart))); + } catch (ex) { + throw _mapApiException(ex) ?? _fallbackApiException(ex); + } + } + + @override + Future> getRewardAddresses() async { + try { + return await _delegate.getRewardAddresses().toDart.then( + (array) => array.toDart + .map((item) => ShelleyAddress(hex.decode(item.toDart))) + .toList(), + ); + } catch (ex) { + throw _mapApiException(ex) ?? _fallbackApiException(ex); + } + } + + @override + Future> getUnusedAddresses() async { + try { + return await _delegate.getUnusedAddresses().toDart.then( + (array) => array.toDart + .map((item) => ShelleyAddress(hex.decode(item.toDart))) + .toList(), + ); + } catch (ex) { + throw _mapApiException(ex) ?? _fallbackApiException(ex); + } + } + + @override + Future> getUsedAddresses({Paginate? paginate}) async { + try { + final jsPaginate = + paginate != null ? JSPaginate.fromDart(paginate) : null; + + return await _delegate.getUsedAddresses(jsPaginate).toDart.then( + (array) => array.toDart + .map((item) => ShelleyAddress(hex.decode(item.toDart))) + .toList(), + ); + } catch (ex) { + throw _mapApiException(ex) ?? + _mapPaginateException(ex) ?? + _fallbackApiException(ex); + } + } + + @override + Future> getUtxos({ + Coin? amount, + Paginate? paginate, + }) async { + try { + return await _delegate + .getUtxos( + amount?.value.toJS, + paginate != null ? JSPaginate.fromDart(paginate) : null, + ) + .toDart + .then( + (array) => array.toDart + .map( + (item) => TransactionUnspentOutput.fromCbor( + cbor.decode(hex.decode(item.toDart)), + ), + ) + .toList(), + ); + } catch (ex) { + throw _mapApiException(ex) ?? + _mapPaginateException(ex) ?? + _fallbackApiException(ex); + } + } + + @override + Future signData({ + required ShelleyAddress address, + required List payload, + }) async { + try { + return await _delegate + .signData( + hex.encode(cbor.encode(address.toCbor())).toJS, + hex.encode(payload).toJS, + ) + .toDart + .then((e) => VkeyWitness.fromCbor(cbor.decode(hex.decode(e.toDart)))); + } catch (ex) { + throw _mapApiException(ex) ?? + _mapDataSignException(ex) ?? + _fallbackApiException(ex); + } + } + + @override + Future signTx({ + required Transaction transaction, + bool partialSign = false, + }) async { + try { + final bytes = cbor.encode(transaction.toCbor()); + final hexString = hex.encode(bytes); + + return await _delegate + .signTx(hexString.toJS, partialSign.toJS) + .toDart + .then( + (e) => TransactionWitnessSet.fromCbor( + cbor.decode(hex.decode(e.toDart)), + ), + ); + } catch (ex) { + throw _mapApiException(ex) ?? + _mapTxSignException(ex) ?? + _fallbackApiException(ex); + } + } + + @override + Future submitTx({required Transaction transaction}) async { + try { + final bytes = cbor.encode(transaction.toCbor()); + final hexString = hex.encode(bytes); + final result = await _delegate.submitTx(hexString.toJS).toDart; + return TransactionHash.fromHex(result.toDart); + } catch (ex) { + throw _mapApiException(ex) ?? + _mapTxSendException(ex) ?? + _fallbackApiException(ex); + } + } +} + +WalletApiException? _mapApiException(Object ex) { + final message = ex.toString(); + + if (message.contains('canceled')) { + throw WalletApiException( + code: WalletApiErrorCode.refused, + info: message, + ); + } + + if (message.contains('unsupported')) { + throw WalletApiException( + code: WalletApiErrorCode.invalidRequest, + info: message, + ); + } + + if (message.contains('account changed')) { + throw WalletApiException( + code: WalletApiErrorCode.accountChange, + info: message, + ); + } + + if (message.contains('unknown')) { + throw WalletApiException( + code: WalletApiErrorCode.internalError, + info: message, + ); + } + + return null; +} + +WalletPaginateException? _mapPaginateException(Object ex) { + final message = ex.toString(); + + // TODO(dtscalac): extract maxSize from underlying JS exception + if (message.contains('page out of range')) { + return const WalletPaginateException(maxSize: -1); + } + return null; +} + +WalletDataSignException? _mapDataSignException(Object ex) { + // TODO(dtscalac): extract exception + return null; +} + +TxSignException? _mapTxSignException(Object ex) { + // TODO(dtscalac): extract exception + return null; +} + +TxSendException? _mapTxSendException(Object ex) { + // TODO(dtscalac): extract exception + return null; +} + +WalletApiException _fallbackApiException(Object ex) { + throw WalletApiException( + code: WalletApiErrorCode.invalidRequest, + info: ex.toString(), + ); +} diff --git a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_web/pubspec.yaml b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_web/pubspec.yaml index 6019e8c805..fe6cb5edbc 100644 --- a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_web/pubspec.yaml +++ b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_web/pubspec.yaml @@ -3,7 +3,7 @@ description: Web platform implementation of catalyst_cardano repository: https://github.com/input-output-hk/catalyst-voices/tree/main/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_web issue_tracker: https://github.com/input-output-hk/catalyst-voices/issues topics: [blockchain, cardano, cryptocurrency, wallet] -version: 1.0.0 +version: 0.1.0 environment: sdk: ^3.3.0 @@ -14,11 +14,17 @@ flutter: implements: catalyst_cardano platforms: web: - pluginClass: CatalystCardanoPlugin + pluginClass: CatalystCardanoWeb fileName: catalyst_cardano_web.dart + assets: + - assets/js/ + dependencies: - catalyst_cardano_platform_interface: ^1.0.0 + catalyst_cardano_platform_interface: ^0.1.0 + catalyst_cardano_serialization: ^0.1.0 + cbor: ^6.2.0 + convert: ^3.1.1 flutter: sdk: flutter flutter_web_plugins: diff --git a/catalyst_voices_packages/catalyst_cardano_serialization/README.md b/catalyst_voices_packages/catalyst_cardano_serialization/README.md index ebb003fad8..b2312df6d3 100644 --- a/catalyst_voices_packages/catalyst_cardano_serialization/README.md +++ b/catalyst_voices_packages/catalyst_cardano_serialization/README.md @@ -122,22 +122,28 @@ void main() { // .withFee(const Coin(10000000)) .buildBody(); - final txHash = TransactionHash.fromTransactionBody(txBody); - final witnessSet = _signTransaction(txHash); + final unsignedTx = Transaction( + body: txBody, + isValid: true, + witnessSet: const TransactionWitnessSet( + vkeyWitnesses: {}, + ), + ); + final witnessSet = _signTransaction(unsignedTx); - final tx = Transaction( + final signedTx = Transaction( body: txBody, isValid: true, witnessSet: witnessSet, auxiliaryData: txMetadata, ); - final txBytes = cbor.encode(tx.toCbor()); + final txBytes = cbor.encode(signedTx.toCbor()); final txBytesHex = hex.encode(txBytes); print(txBytesHex); } -TransactionWitnessSet _signTransaction(TransactionHash txHash) { +TransactionWitnessSet _signTransaction(Transaction transaction) { // return a fake witness set, in real world the wallet // would sign the transaction hash and provide this return TransactionWitnessSet( diff --git a/catalyst_voices_packages/catalyst_cardano_serialization/example/main.dart b/catalyst_voices_packages/catalyst_cardano_serialization/example/main.dart index d67b773ace..6caeb027f3 100644 --- a/catalyst_voices_packages/catalyst_cardano_serialization/example/main.dart +++ b/catalyst_voices_packages/catalyst_cardano_serialization/example/main.dart @@ -77,22 +77,29 @@ void main() { // .withFee(const Coin(10000000)) .buildBody(); - final txHash = TransactionHash.fromTransactionBody(txBody); - final witnessSet = _signTransaction(txHash); + final unsignedTx = Transaction( + body: txBody, + isValid: true, + witnessSet: const TransactionWitnessSet( + vkeyWitnesses: {}, + ), + ); + + final witnessSet = _signTransaction(unsignedTx); - final tx = Transaction( + final signedTx = Transaction( body: txBody, isValid: true, witnessSet: witnessSet, auxiliaryData: txMetadata, ); - final txBytes = cbor.encode(tx.toCbor()); + final txBytes = cbor.encode(signedTx.toCbor()); final txBytesHex = hex.encode(txBytes); print(txBytesHex); } -TransactionWitnessSet _signTransaction(TransactionHash txHash) { +TransactionWitnessSet _signTransaction(Transaction transaction) { // return a fake witness set, in real world the wallet // would sign the transaction hash and provide this return TransactionWitnessSet( diff --git a/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/address.dart b/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/address.dart index ce335c9925..46a22f8f9d 100644 --- a/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/address.dart +++ b/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/address.dart @@ -36,8 +36,9 @@ class ShelleyAddress { final String hrp; /// The constructor for [ShelleyAddress] from raw [bytes] and [hrp]. - ShelleyAddress(List bytes, {this.hrp = defaultAddrHrp}) - : bytes = Uint8List.fromList(bytes); + ShelleyAddress(List bytes) + : bytes = Uint8List.fromList(bytes), + hrp = _extractHrp(bytes); /// The constructor which parses the address from bech32 format. factory ShelleyAddress.fromBech32(String address) { @@ -50,17 +51,16 @@ class ShelleyAddress { switch (hrp) { case defaultAddrHrp: - return ShelleyAddress(_mainNetEncoder.decode(address), hrp: hrp); + return ShelleyAddress(_mainNetEncoder.decode(address)); case const (defaultAddrHrp + testnetHrpSuffix): - return ShelleyAddress(_testNetEncoder.decode(address), hrp: hrp); + return ShelleyAddress(_testNetEncoder.decode(address)); case defaultRewardHrp: - return ShelleyAddress(_mainNetRewardEncoder.decode(address), hrp: hrp); + return ShelleyAddress(_mainNetRewardEncoder.decode(address)); case const (defaultRewardHrp + testnetHrpSuffix): - return ShelleyAddress(_testNetRewardEncoder.decode(address), hrp: hrp); + return ShelleyAddress(_testNetRewardEncoder.decode(address)); default: return ShelleyAddress( Bech32Encoder(hrp: hrp).decode(address), - hrp: hrp, ); } } @@ -74,9 +74,7 @@ class ShelleyAddress { CborValue toCbor() => CborBytes(bytes); /// Returns the [NetworkId] related to this address. - NetworkId get network => NetworkId.testnet.id == (bytes[0] & 0x0f) - ? NetworkId.testnet - : NetworkId.mainnet; + NetworkId get network => _extractNetworkId(bytes); /// Encodes the address in bech32 format. String toBech32() { @@ -130,4 +128,21 @@ class ShelleyAddress { final i = s.indexOf('1'); return s.substring(0, i > 0 ? i : 0); } + + static String _extractHrp(List bytes) { + final header = bytes[0]; + switch (header & 0xF0) { + case 0xE0: + case 0xF0: + return _computeHrp(_extractNetworkId(bytes), defaultRewardHrp); + default: + return _computeHrp(_extractNetworkId(bytes), defaultAddrHrp); + } + } + + static NetworkId _extractNetworkId(List bytes) { + return NetworkId.testnet.id == (bytes[0] & 0x0f) + ? NetworkId.testnet + : NetworkId.mainnet; + } } diff --git a/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/transaction.dart b/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/transaction.dart index c845c81581..20965c6ba9 100644 --- a/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/transaction.dart +++ b/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/transaction.dart @@ -40,8 +40,9 @@ final class Transaction { body: TransactionBody.fromCbor(body), isValid: (isValid as CborBool).value, witnessSet: TransactionWitnessSet.fromCbor(witnessSet), - auxiliaryData: - auxiliaryData != null ? AuxiliaryData.fromCbor(auxiliaryData) : null, + auxiliaryData: (auxiliaryData != null && auxiliaryData is! CborNull) + ? AuxiliaryData.fromCbor(auxiliaryData) + : null, ); } @@ -51,7 +52,7 @@ final class Transaction { body.toCbor(), witnessSet.toCbor(), CborBool(isValid), - (auxiliaryData ?? const AuxiliaryData()).toCbor(), + auxiliaryData?.toCbor() ?? const CborNull(), ]); } } diff --git a/catalyst_voices_packages/catalyst_cardano_serialization/test/address_test.dart b/catalyst_voices_packages/catalyst_cardano_serialization/test/address_test.dart index 4775dd82ce..d124a3667a 100644 --- a/catalyst_voices_packages/catalyst_cardano_serialization/test/address_test.dart +++ b/catalyst_voices_packages/catalyst_cardano_serialization/test/address_test.dart @@ -8,22 +8,22 @@ void main() { group(ShelleyAddress, () { test('round-trip conversion from and to bytes', () { expect( - ShelleyAddress(mainnetAddr.bytes, hrp: mainnetAddr.hrp), + ShelleyAddress(mainnetAddr.bytes), equals(mainnetAddr), ); expect( - ShelleyAddress(testnetAddr.bytes, hrp: testnetAddr.hrp), + ShelleyAddress(testnetAddr.bytes), equals(testnetAddr), ); expect( - ShelleyAddress(mainnetStakeAddr.bytes, hrp: mainnetStakeAddr.hrp), + ShelleyAddress(mainnetStakeAddr.bytes), equals(mainnetStakeAddr), ); expect( - ShelleyAddress(testnetStakeAddr.bytes, hrp: testnetStakeAddr.hrp), + ShelleyAddress(testnetStakeAddr.bytes), equals(testnetStakeAddr), ); }); diff --git a/catalyst_voices_packages/catalyst_cardano_serialization/test/transaction_test.dart b/catalyst_voices_packages/catalyst_cardano_serialization/test/transaction_test.dart index 17217c3f3b..979e7aec90 100644 --- a/catalyst_voices_packages/catalyst_cardano_serialization/test/transaction_test.dart +++ b/catalyst_voices_packages/catalyst_cardano_serialization/test/transaction_test.dart @@ -57,14 +57,14 @@ void main() { test('minimal signed transaction serialized to cbor', () { _testTransactionSerialization( minimalSignedTestTransaction(), - '84a300818258204c1fbc5433ec764164945d736a09dc087d59ff30e64d26d462ff85' - '70cd4be9a700018282581d6082e016828989cd9d809b50d6976d9efa9bc5b2c1a78d' - '4b3bfa1bb83b1a000f424082583900c035332d2dcba35744e880729198459e32c50e' - 'b3e179b1fa2247348c80b846ad416f120db94c1a401992950b11b9bc7d65dbb3424c' - '0f8de41b0000000253fa156b021a00028c55a100818258203311ca404fcf22c91d60' - '7ace285d70e2263a1b81745c39673080329bd1a3f56e584085b3a67a0529c95a740f' - 'd643e2998f03f251268ca603a0778b6631966b9a43fd2e02fa907c610ecc985b375f' - 'a9852c14789dacd2ab7897b445efe4f4b0f60a06f5d90103a0', + '84a300818258204c1fbc5433ec764164945d736a09dc087d59ff30e64d26d462ff857' + '0cd4be9a700018282581d6082e016828989cd9d809b50d6976d9efa9bc5b2c1a78d4b' + '3bfa1bb83b1a000f424082583900c035332d2dcba35744e880729198459e32c50eb3e' + '179b1fa2247348c80b846ad416f120db94c1a401992950b11b9bc7d65dbb3424c0f8d' + 'e41b0000000253fa156b021a00028c55a100818258203311ca404fcf22c91d607ace2' + '85d70e2263a1b81745c39673080329bd1a3f56e584085b3a67a0529c95a740fd643e2' + '998f03f251268ca603a0778b6631966b9a43fd2e02fa907c610ecc985b375fa9852c1' + '4789dacd2ab7897b445efe4f4b0f60a06f5f6', ); }); @@ -81,12 +81,11 @@ void main() { test('minimal unsigned transaction serialized to cbor', () { _testTransactionSerialization( minimalUnsignedTestTransaction(), - '84a300818258204c1fbc5433ec764164945d736a09dc087d59ff30e64d26d4' - '62ff8570cd4be9a700018282581d6082e016828989cd9d809b50d6976d9efa' - '9bc5b2c1a78d4b3bfa1bb83b1a000f424082583900c035332d2dcba35744e8' - '80729198459e32c50eb3e179b1fa2247348c80b846ad416f120db94c1a4019' - '92950b11b9bc7d65dbb3424c0f8de41b0000000253fa156b021a00028c55a0' - 'f5d90103a0', + '84a300818258204c1fbc5433ec764164945d736a09dc087d59ff30e64d26d462ff85' + '70cd4be9a700018282581d6082e016828989cd9d809b50d6976d9efa9bc5b2c1a78d' + '4b3bfa1bb83b1a000f424082583900c035332d2dcba35744e880729198459e32c50e' + 'b3e179b1fa2247348c80b846ad416f120db94c1a401992950b11b9bc7d65dbb3424c' + '0f8de41b0000000253fa156b021a00028c55a0f5f6', ); });