From adcb8762037cdc508647c12e4a2d0d240e54b44d Mon Sep 17 00:00:00 2001 From: poka Date: Mon, 23 May 2022 12:39:01 +0200 Subject: [PATCH 1/3] Add commented code to test endpoint connection --- lib/providers/substrate_sdk.dart | 25 ++++++++++++++++++++++++- pubspec.lock | 2 +- pubspec.yaml | 1 + 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/lib/providers/substrate_sdk.dart b/lib/providers/substrate_sdk.dart index c41d9c7..c8b9694 100644 --- a/lib/providers/substrate_sdk.dart +++ b/lib/providers/substrate_sdk.dart @@ -12,6 +12,7 @@ import 'package:polkawallet_sdk/polkawallet_sdk.dart'; import 'package:polkawallet_sdk/storage/keyring.dart'; import 'package:polkawallet_sdk/storage/types/keyPairData.dart'; import 'package:truncate/truncate.dart'; +// import 'package:web_socket_channel/io.dart'; class SubstrateSdk with ChangeNotifier { final int ss58 = 42; @@ -47,6 +48,28 @@ class SubstrateSdk with ChangeNotifier { n.endpoint = configBox.get('endpoint'); n.ss58 = ss58; node.add(n); + int timeout = 7000; + + // if (n.endpoint!.startsWith('ws://')) { + // timeout = 5000; + // } + + //// Check websocket conenction - only for wss + // final channel = IOWebSocketChannel.connect( + // Uri.parse('wss://192.168.1.72:9944'), + // ); + + // channel.stream.listen( + // (dynamic message) { + // log.d('message $message'); + // }, + // onDone: () { + // log.d('ws channel closed'); + // }, + // onError: (error) { + // log.d('ws error $error'); + // }, + // ); if (sdk.api.connectedNode?.endpoint != null) { await sdk.api.setting.unsubscribeBestNumber(); @@ -55,7 +78,7 @@ class SubstrateSdk with ChangeNotifier { isLoadingEndpoint = true; notifyListeners(); final res = await sdk.api.connectNode(keyring, node).timeout( - const Duration(seconds: 7), + Duration(milliseconds: timeout), onTimeout: () => null, ); isLoadingEndpoint = false; diff --git a/pubspec.lock b/pubspec.lock index 20798a7..7d6ceb5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1356,7 +1356,7 @@ packages: source: hosted version: "1.0.1" web_socket_channel: - dependency: transitive + dependency: "direct main" description: name: web_socket_channel url: "https://pub.dartlang.org" diff --git a/pubspec.yaml b/pubspec.yaml index 0745de2..279e276 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -80,6 +80,7 @@ dependencies: url: https://github.com/poka-IT/sdk.git ref: fixAndroidActivityVersion dots_indicator: ^2.1.0 + web_socket_channel: ^2.2.0 dev_dependencies: # flutter_launcher_icons: ^0.9.2 From 14f784fdc949dd8ad1b1a8f9545d86f862dbd4b2 Mon Sep 17 00:00:00 2001 From: poka Date: Tue, 24 May 2022 16:51:40 +0200 Subject: [PATCH 2/3] Implement identity workflow --- lib/globals.dart | 3 + lib/models/wallet_data.dart | 18 ++- lib/models/wallet_data.g.dart | 31 +++-- lib/providers/chest_provider.dart | 17 ++- lib/providers/generate_wallets.dart | 1 + lib/providers/my_wallets.dart | 1 + lib/providers/substrate_sdk.dart | 84 ++++++++++-- lib/providers/wallet_options.dart | 123 +++++++++++++++++- lib/screens/myWallets/choose_wallet.dart | 2 +- .../myWallets/confirm_wallet_storage.dart | 5 +- lib/screens/myWallets/generate_wallets.dart | 2 +- lib/screens/myWallets/wallet_options.dart | 8 +- lib/screens/onBoarding/9.dart | 2 +- lib/screens/wallet_view.dart | 40 +++--- 14 files changed, 280 insertions(+), 57 deletions(-) diff --git a/lib/globals.dart b/lib/globals.dart index 5ff7013..69f5154 100644 --- a/lib/globals.dart +++ b/lib/globals.dart @@ -7,6 +7,9 @@ import 'package:hive/hive.dart'; import 'package:logger/logger.dart'; import 'package:shared_preferences/shared_preferences.dart'; +// Version of box data +const int dataVersion = 1; + // Files paths Directory? appPath; diff --git a/lib/models/wallet_data.dart b/lib/models/wallet_data.dart index 4943f51..c13b894 100644 --- a/lib/models/wallet_data.dart +++ b/lib/models/wallet_data.dart @@ -5,28 +5,32 @@ part 'wallet_data.g.dart'; @HiveType(typeId: 0) class WalletData extends HiveObject { @HiveField(0) - int? chest; + int? version; @HiveField(1) - String? address; + int? chest; @HiveField(2) - int? number; + String? address; @HiveField(3) - String? name; + int? number; @HiveField(4) - int? derivation; + String? name; @HiveField(5) - String? imageName; + int? derivation; @HiveField(6) + String? imageName; + + @HiveField(7) File? imageFile; WalletData( - {this.chest, + {this.version, + this.chest, this.address, this.number, this.name, diff --git a/lib/models/wallet_data.g.dart b/lib/models/wallet_data.g.dart index d6ba5ef..b577e03 100644 --- a/lib/models/wallet_data.g.dart +++ b/lib/models/wallet_data.g.dart @@ -17,33 +17,36 @@ class WalletDataAdapter extends TypeAdapter { for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), }; return WalletData( - chest: fields[0] as int?, - address: fields[1] as String?, - number: fields[2] as int?, - name: fields[3] as String?, - derivation: fields[4] as int?, - imageName: fields[5] as String?, - imageFile: fields[6] as File?, + version: fields[0] as int?, + chest: fields[1] as int?, + address: fields[2] as String?, + number: fields[3] as int?, + name: fields[4] as String?, + derivation: fields[5] as int?, + imageName: fields[6] as String?, + imageFile: fields[7] as File?, ); } @override void write(BinaryWriter writer, WalletData obj) { writer - ..writeByte(7) + ..writeByte(8) ..writeByte(0) - ..write(obj.chest) + ..write(obj.version) ..writeByte(1) - ..write(obj.address) + ..write(obj.chest) ..writeByte(2) - ..write(obj.number) + ..write(obj.address) ..writeByte(3) - ..write(obj.name) + ..write(obj.number) ..writeByte(4) - ..write(obj.derivation) + ..write(obj.name) ..writeByte(5) - ..write(obj.imageName) + ..write(obj.derivation) ..writeByte(6) + ..write(obj.imageName) + ..writeByte(7) ..write(obj.imageFile); } diff --git a/lib/providers/chest_provider.dart b/lib/providers/chest_provider.dart index 9c7250b..8127581 100644 --- a/lib/providers/chest_provider.dart +++ b/lib/providers/chest_provider.dart @@ -2,6 +2,9 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:gecko/globals.dart'; import 'package:gecko/models/chest_data.dart'; +import 'package:gecko/models/wallet_data.dart'; +import 'package:gecko/providers/substrate_sdk.dart'; +import 'package:provider/provider.dart'; class ChestProvider with ChangeNotifier { void rebuildWidget() { @@ -10,8 +13,9 @@ class ChestProvider with ChangeNotifier { Future deleteChest(context, ChestData _chest) async { final bool? _answer = await (_confirmDeletingChest(context, _chest.name)); - + SubstrateSdk _sub = Provider.of(context, listen: false); if (_answer!) { + await _sub.deleteAccounts(getChestWallets(_chest)); await chestBox.delete(_chest.key); if (chestBox.isEmpty) { await configBox.put('currentChest', 0); @@ -28,6 +32,17 @@ class ChestProvider with ChangeNotifier { } } + List getChestWallets(ChestData _chest) { + List toDelete = []; + log.d(_chest.key); + walletBox.toMap().forEach((key, WalletData value) { + if (value.chest == _chest.key) { + toDelete.add(value.address!); + } + }); + return toDelete; + } + Future _confirmDeletingChest(context, String? _walletName) async { return showDialog( context: context, diff --git a/lib/providers/generate_wallets.dart b/lib/providers/generate_wallets.dart index ffa30da..5cc67e3 100644 --- a/lib/providers/generate_wallets.dart +++ b/lib/providers/generate_wallets.dart @@ -81,6 +81,7 @@ class GenerateWalletsProvider with ChangeNotifier { int? chestKey = chestBox.keys.last; WalletData myWallet = WalletData( + version: dataVersion, chest: chestKey, address: address, number: 0, diff --git a/lib/providers/my_wallets.dart b/lib/providers/my_wallets.dart index 2f12bae..81a36bc 100644 --- a/lib/providers/my_wallets.dart +++ b/lib/providers/my_wallets.dart @@ -169,6 +169,7 @@ class MyWalletsProvider with ChangeNotifier { context, _currentChest.address!, _newDerivationNbr, pinCode); WalletData newWallet = WalletData( + version: dataVersion, chest: _chest, address: address, number: _newWalletNbr, diff --git a/lib/providers/substrate_sdk.dart b/lib/providers/substrate_sdk.dart index c8b9694..1ee42d8 100644 --- a/lib/providers/substrate_sdk.dart +++ b/lib/providers/substrate_sdk.dart @@ -232,6 +232,13 @@ class SubstrateSdk with ChangeNotifier { } } + Future deleteAccounts(List address) async { + for (var a in address) { + final account = getKeypair(a); + await sdk.api.keyring.deleteAccount(keyring, account); + } + } + Future generateMnemonic({String lang = appLang}) async { final gen = await sdk.api.keyring.generateMnemonic(ss58); generatedMnemonic = gen.mnemonic!; @@ -294,6 +301,11 @@ class SubstrateSdk with ChangeNotifier { required String password}) async { setCurrentWallet(fromAddress); + log.d(keyring.current.address); + log.d(fromAddress); + log.d(password); + log.d(await checkPassword(fromAddress, password)); + final sender = TxSenderData( keyring.current.address, keyring.current.pubKey, @@ -318,25 +330,76 @@ class SubstrateSdk with ChangeNotifier { } } + Future idtyStatus(String address) async { + // var tata = await sdk.webView! + // .evalJavascript('api.query.system.account("$address")'); + + var idtyIndex = await sdk.webView! + .evalJavascript('api.query.identity.identityIndexOf("$address")'); + + if (idtyIndex == null) { + return 'noid'; + } + + final idtyStatus = await sdk.webView! + .evalJavascript('api.query.identity.identities($idtyIndex)'); + + if (idtyStatus != null) { + final String _status = idtyStatus['status']; + log.d(_status); + return (_status); + } else { + return 'expired'; + } + } + + Future confirmIdentity( + String address, String name, String password) async { + // Confirm identity + setCurrentWallet(address); + log.d('idty: ' + keyring.current.address!); + + final sender = TxSenderData( + keyring.current.address, + keyring.current.pubKey, + ); + + final txInfo = TxInfoData( + 'identity', + 'confirmIdentity', + sender, + ); + + try { + final tata = await sdk.api.tx.signAndSend( + txInfo, + [name], + password, + ); + log.d(tata); + return 'confirmed'; + } on Exception catch (e) { + log.e(e); + // if (e.toString() == 'Exception: password check failed') { + // throw PasswordException('Bad password'); + // } + return e.toString(); + } + } + Future derive( BuildContext context, String address, int number, String password) async { final keypair = getKeypair(address); final seedMap = await keyring.store.getDecryptedSeed(keypair.pubKey, password); - print(seedMap); if (seedMap?['type'] != 'mnemonic') return ''; final List seedList = seedMap!['seed'].split('//'); generatedMnemonic = seedList[0]; - int sourceDerivation = -1; // To get derivation number of this account - if (seedList.length > 1) { - sourceDerivation = int.parse(seedList[1]); - } - print(generatedMnemonic); - print(sourceDerivation); - return await importAccount(fromMnemonic: true, derivePath: '//$number'); + return await importAccount( + fromMnemonic: true, derivePath: '//$number', password: password); } Future isMnemonicValid(String mnemonic) async { @@ -392,3 +455,8 @@ String getShortPubkey(String pubkey) { return pubkeyShort; } + +class PasswordException implements Exception { + String cause; + PasswordException(this.cause); +} diff --git a/lib/providers/wallet_options.dart b/lib/providers/wallet_options.dart index 9f2c4a1..9c350f4 100644 --- a/lib/providers/wallet_options.dart +++ b/lib/providers/wallet_options.dart @@ -148,6 +148,124 @@ class WalletOptionsProvider with ChangeNotifier { } } + Widget idtyStatus(BuildContext context, String address, + {bool isOwner = false}) { + return Consumer(builder: (context, _sub, _) { + return FutureBuilder( + future: _sub.idtyStatus(address), + initialData: '...', + builder: (context, snapshot) { + switch (snapshot.data.toString()) { + case 'noid': + { + return Column(children: const [ + Text( + 'Aucune identité', + style: TextStyle(fontSize: 18, color: Colors.black), + ), + ]); + } + case 'Created': + { + return Column(children: [ + isOwner + ? InkWell( + child: const Text( + 'Identité créé, cliquez pour la confirmer', + style: + TextStyle(fontSize: 18, color: Colors.black), + ), + onTap: () async { + await validateIdentity(context); + }, + ) + : const Text( + 'Identité créé', + style: TextStyle(fontSize: 18, color: Colors.black), + ), + ]); + } + case 'ConfirmedByOwner': + { + return Column(children: const [ + Text( + 'Identité confirmé', + style: TextStyle(fontSize: 18, color: Colors.black), + ), + ]); + } + + case 'Validated': + { + return Column(children: const [ + Text( + 'Membre validé !', + style: TextStyle(fontSize: 18, color: Colors.black), + ), + ]); + } + + case 'expired': + { + return Column(children: const [ + Text( + 'Identité expiré', + style: TextStyle(fontSize: 18, color: Colors.black), + ), + ]); + } + } + return SizedBox( + width: 230, + child: Column(children: const [ + Text( + 'Statut inconnu', + style: TextStyle(fontSize: 18, color: Colors.black), + ), + ]), + ); + }); + }); + } + + Future validateIdentity(BuildContext context) async { + TextEditingController idtyName = TextEditingController(); + SubstrateSdk _sub = Provider.of(context, listen: false); + WalletOptionsProvider _walletOptions = + Provider.of(context, listen: false); + MyWalletsProvider _myWalletProvider = + Provider.of(context, listen: false); + + return showDialog( + context: context, + barrierDismissible: true, // user must tap button! + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Confirmez votre identité'), + content: SizedBox( + height: 100, + child: Column(children: [ + const Text('Nom:'), + TextField( + controller: idtyName, + ) + ]), + ), + actions: [ + TextButton( + child: const Text("Valider"), + onPressed: () async { + _sub.confirmIdentity(_walletOptions.address.text, idtyName.text, + _myWalletProvider.pinCode); + Navigator.pop(context); + }, + ), + ], + ); + }, + ); + } + void reloadBuild() { notifyListeners(); } @@ -167,9 +285,10 @@ class WalletOptionsProvider with ChangeNotifier { } } -Widget balance(BuildContext context, String address, double size) { - String balanceCache = ''; +// Map balanceCache = {}; +String balanceCache = ''; +Widget balance(BuildContext context, String address, double size) { return Column(children: [ Consumer(builder: (context, _sdk, _) { return FutureBuilder( diff --git a/lib/screens/myWallets/choose_wallet.dart b/lib/screens/myWallets/choose_wallet.dart index 0c550ff..7fabd0c 100644 --- a/lib/screens/myWallets/choose_wallet.dart +++ b/lib/screens/myWallets/choose_wallet.dart @@ -58,7 +58,7 @@ class ChooseWalletScreen extends StatelessWidget { destAddress: _walletViewProvider.outputPubkey.text, amount: double.parse(_walletViewProvider.payAmount.text), - password: pin); + password: pin.toUpperCase()); await paymentsResult(context, resultPay); }, child: const Text( diff --git a/lib/screens/myWallets/confirm_wallet_storage.dart b/lib/screens/myWallets/confirm_wallet_storage.dart index 801ab1c..3f2352c 100644 --- a/lib/screens/myWallets/confirm_wallet_storage.dart +++ b/lib/screens/myWallets/confirm_wallet_storage.dart @@ -140,8 +140,9 @@ class ConfirmStoreWallet extends StatelessWidget with ChangeNotifier { fromMnemonic: true, mnemonic: _generateWalletProvider .generatedMnemonic!, - password: - _generateWalletProvider.pin.text, + password: _generateWalletProvider + .pin.text + .toUpperCase(), derivePath: '//2'); await _generateWalletProvider.storeHDWChest( address, diff --git a/lib/screens/myWallets/generate_wallets.dart b/lib/screens/myWallets/generate_wallets.dart index fe3a24e..f6c91a7 100644 --- a/lib/screens/myWallets/generate_wallets.dart +++ b/lib/screens/myWallets/generate_wallets.dart @@ -26,7 +26,7 @@ class GenerateFastChestScreen extends StatelessWidget { _generateWalletProvider.pin.text = kDebugMode && debugPin ? 'AAAAA' - : _generateWalletProvider.changePinCode(reload: false); + : _generateWalletProvider.changePinCode(reload: false).toUpperCase(); return WillPopScope( onWillPop: () { diff --git a/lib/screens/myWallets/wallet_options.dart b/lib/screens/myWallets/wallet_options.dart index 19a39fc..fa9bdcf 100644 --- a/lib/screens/myWallets/wallet_options.dart +++ b/lib/screens/myWallets/wallet_options.dart @@ -90,6 +90,10 @@ class WalletOptions extends StatelessWidget { walletName(walletProvider, _walletOptions), SizedBox(height: isTall ? 5 : 0), balance(context, walletProvider.address.text, 20), + SizedBox(height: isTall ? 5 : 0), + _walletOptions.idtyStatus( + context, _walletOptions.address.text, + isOwner: true), ]), const Spacer(flex: 3), ]), @@ -212,8 +216,8 @@ class WalletOptions extends StatelessWidget { walletProvider.isEditing ? 'assets/walletOptions/android-checkmark.png' : 'assets/walletOptions/edit.png', - width: 20, - height: 20), + width: 25, + height: 25), ), ), ), diff --git a/lib/screens/onBoarding/9.dart b/lib/screens/onBoarding/9.dart index 61dc4f6..d7d7d2f 100644 --- a/lib/screens/onBoarding/9.dart +++ b/lib/screens/onBoarding/9.dart @@ -23,7 +23,7 @@ class OnboardingStepThirteen extends StatelessWidget { _generateWalletProvider.pin.text = kDebugMode && debugPin ? 'AAAAA' - : _generateWalletProvider.changePinCode(reload: false); + : _generateWalletProvider.changePinCode(reload: false).toUpperCase(); return Scaffold( appBar: AppBar( diff --git a/lib/screens/wallet_view.dart b/lib/screens/wallet_view.dart index 12aa878..18cabb5 100644 --- a/lib/screens/wallet_view.dart +++ b/lib/screens/wallet_view.dart @@ -27,7 +27,6 @@ class WalletViewScreen extends StatelessWidget { Provider.of(context, listen: false); CesiumPlusProvider _cesiumPlusProvider = Provider.of(context, listen: false); - _historyProvider.pubkey = pubkey!; return Scaffold( @@ -285,21 +284,21 @@ class WalletViewScreen extends StatelessWidget { primary: orangeC, // background onPrimary: Colors.white, // foreground ), - onPressed: _walletViewProvider.payAmount.text != - '' - ? () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) { - return UnlockingWallet( - wallet: defaultWallet, - action: "pay"); - }, - ), - ); - } - : null, + onPressed: + _walletViewProvider.payAmount.text != '' + ? () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) { + return UnlockingWallet( + wallet: defaultWallet, + action: "pay"); + }, + ), + ); + } + : null, child: const Text( 'Effectuer le virement', style: TextStyle( @@ -325,6 +324,9 @@ class WalletViewScreen extends StatelessWidget { CesiumPlusProvider _cesiumPlusProvider) { const double _avatarSize = 140; + WalletOptionsProvider _walletOptions = + Provider.of(context, listen: false); + return Column(children: [ Container( height: 10, @@ -365,9 +367,11 @@ class WalletViewScreen extends StatelessWidget { const SizedBox(height: 25), Consumer( builder: (context, walletProvider, _) { - return balance(context, pubkey!, 20); + return balance(context, pubkey!, 22); }), - //// + const SizedBox(height: 10), + _walletOptions.idtyStatus(context, pubkey!, isOwner: false), + // if (username == null && // g1WalletsBox.get(pubkey)?.username == null) // Query( From 04ffabf0523191f0c7faf2bf7f4474372aa2029e Mon Sep 17 00:00:00 2001 From: poka Date: Tue, 24 May 2022 18:31:41 +0200 Subject: [PATCH 3/3] fix: bad cache on balances --- lib/providers/wallet_options.dart | 26 ++++++++++++++++++-------- pubspec.lock | 12 ++++++------ pubspec.yaml | 2 +- 3 files changed, 25 insertions(+), 15 deletions(-) diff --git a/lib/providers/wallet_options.dart b/lib/providers/wallet_options.dart index 9c350f4..ee3a6e0 100644 --- a/lib/providers/wallet_options.dart +++ b/lib/providers/wallet_options.dart @@ -285,8 +285,7 @@ class WalletOptionsProvider with ChangeNotifier { } } -// Map balanceCache = {}; -String balanceCache = ''; +Map balanceCache = {}; Widget balance(BuildContext context, String address, double size) { return Column(children: [ @@ -296,14 +295,25 @@ Widget balance(BuildContext context, String address, double size) { builder: (BuildContext context, AsyncSnapshot _balance) { if (_balance.connectionState != ConnectionState.done || _balance.hasError) { - return Text(balanceCache, - style: TextStyle( - fontSize: isTall ? size : size * 0.9, - )); + if (balanceCache[address] != null) { + return Text(balanceCache[address]!, + style: TextStyle( + fontSize: isTall ? size : size * 0.9, + )); + } else { + return SizedBox( + height: 15, + width: 15, + child: CircularProgressIndicator( + color: orangeC, + strokeWidth: 2, + ), + ); + } } - balanceCache = "${_balance.data.toString()} $currencyName"; + balanceCache[address] = "${_balance.data.toString()} $currencyName"; return Text( - balanceCache, + balanceCache[address]!, style: TextStyle( fontSize: isTall ? size : 18, ), diff --git a/pubspec.lock b/pubspec.lock index 7d6ceb5..4d4c0e2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -140,7 +140,7 @@ packages: name: built_value url: "https://pub.dartlang.org" source: hosted - version: "8.3.0" + version: "8.3.2" carousel_slider: dependency: "direct main" description: @@ -259,7 +259,7 @@ packages: name: cross_file url: "https://pub.dartlang.org" source: hosted - version: "0.3.3" + version: "0.3.3+1" crypto: dependency: "direct main" description: @@ -431,7 +431,7 @@ packages: name: get url: "https://pub.dartlang.org" source: hosted - version: "4.6.3" + version: "4.6.5" get_storage: dependency: transitive description: @@ -636,7 +636,7 @@ packages: name: infinite_scroll_pagination url: "https://pub.dartlang.org" source: hosted - version: "3.1.0" + version: "3.2.0" integration_test: dependency: "direct dev" description: flutter @@ -999,7 +999,7 @@ packages: description: path: printing ref: HEAD - resolved-ref: "95bbc1f33b7cb45f7a4b98088b96bc541fefc495" + resolved-ref: "96fe6ef3c1ee702a86dd311b3feebdb1f7491cd6" url: "https://github.com/DavBfr/dart_pdf.git" source: git version: "5.9.1" @@ -1016,7 +1016,7 @@ packages: name: provider url: "https://pub.dartlang.org" source: hosted - version: "6.0.2" + version: "6.0.3" pub_semver: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 279e276..29c3b2d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -5,7 +5,7 @@ description: Pay with G1. # pub.dev using `pub publish`. This is preferred for private packages. publish_to: 'none' # Remove this line if you wish to publish to pub.dev -version: 0.0.5+3 +version: 0.0.6+2 environment: sdk: '>=2.12.0 <3.0.0'