From ad394cb6c41106f7e02e7f3f372330b0fd899391 Mon Sep 17 00:00:00 2001 From: ptisserand Date: Wed, 8 Jan 2025 15:58:46 +0100 Subject: [PATCH] fix: update wallet_app tutorial (#431) * docs: add step about Android minimun SDK version required by `wallet_kit` * feat(wallet_kit): add deploy account support * fix(wallet_kit): refresh accounts ETH balance when wallet is expanded * wallet_kit: fix analyze issue * fix(wallet_kit): disable 'Send ETH' button when account is not deployed * docs: add missing Scaffold in mobile wallet tutorial * fix(wallet_kit): use `TokenSymbol.name` instead of hardcoded string for account balances keys * fix(wallet_kit): disable 'Deploy account' button if ETH account balance is not enough * feat(wallet_kit): also refresh STRK balance when wallet is expanded * docs: add info for Android biometric support and devnet minting in mobile-wallet tutorial * fix(wallet_kit): check if account is deployed when wallet is expanded * fix(wallet_kit): ensure private key is not null before signer creation * wallet_kit: apply some code rabbit recommendation * docs: fix typo --- docs/examples/mobile-wallet.mdx | 83 +++++++++++++++---- .../lib/services/wallet_service.dart | 39 ++++++++- .../lib/wallet_state/wallet_provider.dart | 39 ++++++++- .../lib/widgets/deploy_account_button.dart | 39 +++++++++ packages/wallet_kit/lib/widgets/index.dart | 1 + .../lib/widgets/send_eth_button.dart | 31 +++---- .../wallet_kit/lib/widgets/token_list.dart | 15 ++-- .../wallet_kit/lib/widgets/wallet_list.dart | 34 +++++++- 8 files changed, 240 insertions(+), 41 deletions(-) create mode 100644 packages/wallet_kit/lib/widgets/deploy_account_button.dart diff --git a/docs/examples/mobile-wallet.mdx b/docs/examples/mobile-wallet.mdx index 3667f1fe..bf06654e 100644 --- a/docs/examples/mobile-wallet.mdx +++ b/docs/examples/mobile-wallet.mdx @@ -23,11 +23,18 @@ flutter pub add wallet_kit hive_flutter hooks_riverpod flutter_dotenv 4. Create a `.env` file in the root of your wallet_app project ```bash -ACCOUNT_CLASS_HASH="0x05400e90f7e0ae78bd02c77cd75527280470e2fe19c54970dd79dc37a9d3645c" +ACCOUNT_CLASS_HASH="0x061dac032f228abef9c6626f995015233097ae253a7f72d68552db02f2971b8f" RPC="http://127.0.0.1:5050/rpc" ``` -> If you are building for Android, use `RPC="http://10.0.2.2:5050/rpc"` instead. +> Please note that `ACCOUNT_CLASS_HASH` must match the one used by your version of `starknet-devnet`, it's displayed at startup. +Here is the value for `starknet-devnet 0.2.0` +> ``` +> Predeployed accounts using class with hash: 0x061dac032f228abef9c6626f995015233097ae253a7f72d68552db02f2971b8f +> ``` + +> If you are running on another device that the host running `starknet-devnet`, you should use the external IP of your host running and start `starknet-devnet` with `--host 0.0.0.0` argument + 5. Add `.env` file in your `pubspec.yaml` ```yaml @@ -35,6 +42,32 @@ RPC="http://127.0.0.1:5050/rpc" - .env ``` +6. Update Android minimun SDK version + +`secure_store` package used by `wallet_kit` require Android minimum SDK version set to at least 23, you need to modify `android/app/build.gradle`: +``` + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.example.wallet_app" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = 23 + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } +``` + +7. Biometric support (optional) + +In order to use `Biometric` on Android, your `MainActivity` must inherit from `FlutterFragmentActivity` instead of `FlutterActity`. +You need to modify your `MainActivity.kt` with: +```kotlin +import io.flutter.embedding.android.FlutterFragmentActivity + +class MainActivity: FlutterFragmentActivity() +``` + ## Let's write some code Let's start with a simple `main` function in your 'main.dart' file. @@ -174,21 +207,24 @@ class HomeScreen extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - return const Layout2( - children: [ - SizedBox(height: 32), - Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - WalletSelector(), - AccountAddress(), - ], - ), - SizedBox(height: 32), - WalletBody(), - SendEthButton(), - ], + return const Scaffold( + body: Layout2( + children: [ + SizedBox(height: 32), + Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + WalletSelector(), + AccountAddress(), + DeployAccountButton(), + ], + ), + SizedBox(height: 32), + WalletBody(), + SendEthButton(), + ], + ), ); } } @@ -215,3 +251,16 @@ class WalletApp extends HookConsumerWidget { ``` Now you can run your app with `flutter run` and see your wallet in action! 💸 + +--- + +Deploying an account requires some ETH to pay transaction fees. +With `starknet-devnet`, you can mint some ETH to your account address with the following command: +```shell +curl --silent -H 'Content-type: application/json' \ + -X POST http://localhost:5050/mint \ + -d '{"address": """", "amount": 20000000000000000000, "unit": "WEI"}' +``` +```console +{"new_balance":"20000000000000000000","unit":"WEI","tx_hash":"0x9d2d26cef777c50b64475592e0df6e6c6012014e660f97bb37aaf5138aff54"} +``` diff --git a/packages/wallet_kit/lib/services/wallet_service.dart b/packages/wallet_kit/lib/services/wallet_service.dart index 89cb068b..c4784576 100644 --- a/packages/wallet_kit/lib/services/wallet_service.dart +++ b/packages/wallet_kit/lib/services/wallet_service.dart @@ -144,6 +144,42 @@ class WalletService { ); return address; } + + static Future deployAccount({ + required SecureStore secureStore, + required Account account, + }) async { + final privateKey = await secureStore.getSecret( + key: privateKeyKey(account.walletId, account.id)); + if (privateKey == null) { + throw Exception("Private key not found"); + } + + s.Signer? signer = s.Signer(privateKey: s.Felt.fromHexString(privateKey)); + + final provider = WalletKit().provider; + + // call data depends on class hash... + final constructorCalldata = [signer.publicKey]; + final tx = await s.Account.deployAccount( + signer: signer, + provider: provider, + constructorCalldata: constructorCalldata, + classHash: WalletKit().accountClassHash, + ); + signer = null; + final (contractAddress, txHash) = tx.when( + result: (result) => + (result.contractAddress, result.transactionHash.toHexString()), + error: (error) => throw Exception('${error.code}: ${error.message}'), + ); + bool success = await s.waitForAcceptance( + transactionHash: txHash, + provider: provider, + ); + + return success; + } } seedPhraseKey(String walletId) { @@ -168,12 +204,13 @@ Future sendEth({ s.Signer? signer = s.Signer(privateKey: privateKey); final provider = WalletKit().provider; + final chainId = WalletKit().chainId; final fundingAccount = s.Account( provider: provider, signer: signer, accountAddress: s.Felt.fromHexString(account.address), - chainId: s.StarknetChainId.testNet, + chainId: chainId, ); final txHash = await fundingAccount.send( diff --git a/packages/wallet_kit/lib/wallet_state/wallet_provider.dart b/packages/wallet_kit/lib/wallet_state/wallet_provider.dart index 10bd36c6..f5736868 100644 --- a/packages/wallet_kit/lib/wallet_state/wallet_provider.dart +++ b/packages/wallet_kit/lib/wallet_state/wallet_provider.dart @@ -13,6 +13,8 @@ class Wallets extends _$Wallets with PersistedState { @override String get boxName => 'wallet'; + bool _isRefreshing = false; + @override WalletsState fromJson(Map json) => WalletsState.fromJson(json); @@ -128,6 +130,19 @@ class Wallets extends _$Wallets with PersistedState { ); } + deployAccount({ + required SecureStore secureStore, + required Account account, + }) async { + final success = await WalletService.deployAccount( + secureStore: secureStore, account: account); + updateSelectedAccountIsDeployed( + walletId: account.walletId, + accountId: account.id, + isDeployed: success, + ); + } + updateSelectedAccountIsDeployed({ required String walletId, required int accountId, @@ -171,6 +186,25 @@ class Wallets extends _$Wallets with PersistedState { state = state.copyWith(wallets: {}, selected: null); } + Future refreshAccount(String walletId, int accountId) async { + if (_isRefreshing) return; + _isRefreshing = true; + try { + await refreshEthBalance(walletId, accountId); + await refreshStrkBalance(walletId, accountId); + final isDeployed = await WalletService.isAccountValid( + account: state.wallets[walletId]!.accounts[accountId]!, + ); + updateSelectedAccountIsDeployed( + walletId: walletId, + accountId: accountId, + isDeployed: isDeployed, + ); + } finally { + _isRefreshing = false; + } + } + refreshEthBalance(String walletId, int accountId) async { final accountAddress = state.wallets[walletId]?.accounts[accountId]?.address; @@ -198,7 +232,7 @@ class Wallets extends _$Wallets with PersistedState { accountId: account.copyWith( balances: { ...account.balances, - 'ETH': double.parse(ethBalance.toStringAsFixed(4)), + TokenSymbol.ETH.name: double.parse(ethBalance.toStringAsFixed(4)), }, ), }, @@ -233,7 +267,8 @@ class Wallets extends _$Wallets with PersistedState { accountId: account.copyWith( balances: { ...account.balances, - 'STRK': double.parse(strkBalance.toStringAsFixed(4)), + TokenSymbol.STRK.name: + double.parse(strkBalance.toStringAsFixed(4)), }, ), }, diff --git a/packages/wallet_kit/lib/widgets/deploy_account_button.dart b/packages/wallet_kit/lib/widgets/deploy_account_button.dart new file mode 100644 index 00000000..cdba27c1 --- /dev/null +++ b/packages/wallet_kit/lib/widgets/deploy_account_button.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../wallet_kit.dart'; + +class DeployAccountButton extends HookConsumerWidget { + const DeployAccountButton({ + super.key, + }); + + // ignore: constant_identifier_names + static const double MINIMUN_ETH_BALANCE = 0.00001; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final selectedAccount = ref.watch( + walletsProvider.select((value) => value.selectedAccount), + ); + if (selectedAccount?.isDeployed == false) { + final ethBalance = + selectedAccount!.balances[TokenSymbol.ETH.name] ?? 0.00; + final enoughBalance = ethBalance >= MINIMUN_ETH_BALANCE; + return PrimaryButton( + label: enoughBalance ? 'Deploy account' : 'Not enough ETH', + onPressed: enoughBalance + ? () async { + final secureStore = await ref + .read(walletsProvider.notifier) + .getSecureStoreForWallet(context: context); + await ref.read(walletsProvider.notifier).deployAccount( + secureStore: secureStore, + account: selectedAccount, + ); + } + : null); + } else { + return const SizedBox.shrink(); + } + } +} diff --git a/packages/wallet_kit/lib/widgets/index.dart b/packages/wallet_kit/lib/widgets/index.dart index 727a5c5f..f8e6e80e 100644 --- a/packages/wallet_kit/lib/widgets/index.dart +++ b/packages/wallet_kit/lib/widgets/index.dart @@ -8,3 +8,4 @@ export 'send_eth_button.dart'; export 'account_address.dart'; export 'nft_list.dart'; export 'nft_details.dart'; +export 'deploy_account_button.dart'; diff --git a/packages/wallet_kit/lib/widgets/send_eth_button.dart b/packages/wallet_kit/lib/widgets/send_eth_button.dart index 8d720d99..2380b395 100644 --- a/packages/wallet_kit/lib/widgets/send_eth_button.dart +++ b/packages/wallet_kit/lib/widgets/send_eth_button.dart @@ -16,22 +16,25 @@ class SendEthButton extends HookConsumerWidget { final selectedAccount = ref.watch(walletsProvider.select( (value) => value.selectedAccount, )); + if (selectedAccount == null) { + return const SizedBox.shrink(); + } + return PrimaryButton( label: 'Send', - onPressed: () async { - if (selectedAccount == null) { - throw Exception('Account is required'); - } - final password = await showPasswordModal(context); - if (password == null) { - throw Exception('Password is required'); - } - await sendEth( - account: selectedAccount, - password: password, - recipientAddress: recipientAddress, - amount: 0.001); - }, + onPressed: selectedAccount.isDeployed + ? () async { + final password = await showPasswordModal(context); + if (password == null) { + throw Exception('Password is required'); + } + await sendEth( + account: selectedAccount, + password: password, + recipientAddress: recipientAddress, + amount: 0.001); + } + : null, ); } } diff --git a/packages/wallet_kit/lib/widgets/token_list.dart b/packages/wallet_kit/lib/widgets/token_list.dart index 860ed536..77b543f9 100644 --- a/packages/wallet_kit/lib/widgets/token_list.dart +++ b/packages/wallet_kit/lib/widgets/token_list.dart @@ -14,10 +14,15 @@ class TokenList extends HookConsumerWidget { useEffect(() { if (selectedAccount != null) { - ref.read(walletsProvider.notifier).refreshEthBalance( - selectedAccount.walletId, - selectedAccount.id, - ); + ref.read(walletsProvider.notifier) + ..refreshEthBalance( + selectedAccount.walletId, + selectedAccount.id, + ) + ..refreshStrkBalance( + selectedAccount.walletId, + selectedAccount.id, + ); } return; }, [selectedAccount?.id]); @@ -44,7 +49,7 @@ class TokenListItem extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final balance = ref.watch(walletsProvider.select( - (value) => value.selectedAccount?.balances[symbol] ?? 0.00, + (value) => value.selectedAccount?.balances[symbol.name] ?? 0.00, )); return Padding( diff --git a/packages/wallet_kit/lib/widgets/wallet_list.dart b/packages/wallet_kit/lib/widgets/wallet_list.dart index 26b9c0e4..2e748c2c 100644 --- a/packages/wallet_kit/lib/widgets/wallet_list.dart +++ b/packages/wallet_kit/lib/widgets/wallet_list.dart @@ -130,7 +130,33 @@ class WalletCell extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final controller = useState(null); + useEffect(() { + controller.value = ExpandableController(); + return () => controller.value?.dispose(); + }, []); + + useEffect(() { + if (controller.value != null) { + void onExpanded() { + if (controller.value!.expanded) { + wallet.accounts.forEach( + (key, value) => ref + .read(walletsProvider.notifier) + .refreshAccount(value.walletId, value.id), + ); + } + } + + controller.value!.addListener(onExpanded); + return () => controller.value?.removeListener(onExpanded); + } else { + return null; + } + }, [controller.value]); + return ExpandableNotifier( + controller: controller.value, child: Container( decoration: BoxDecoration( color: Colors.grey.withValues(alpha: 0.1), @@ -205,12 +231,15 @@ class AccountCell extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final isDeployed = account.isDeployed; return FilledButton.tonal( style: ButtonStyle( padding: WidgetStateProperty.all( const EdgeInsets.symmetric(horizontal: 16, vertical: 16), ), - backgroundColor: WidgetStateProperty.all(Colors.white), + backgroundColor: WidgetStateProperty.all(isDeployed + ? Colors.white + : Colors.grey.shade400.withValues(alpha: 0.5)), side: WidgetStateProperty.all(BorderSide.none), overlayColor: WidgetStateProperty.all(Colors.grey.withValues(alpha: 0.05)), @@ -241,7 +270,8 @@ class AccountCell extends HookConsumerWidget { Text(formatAddress(account.address)), ], ), - Text('${(account.balances['ETH'] ?? 0).toString()} ETH'), + Text( + '${(account.balances[TokenSymbol.ETH.name] ?? 0).toString()} ETH'), ], ), );