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'),
],
),
);