Skip to content
Mobile

Architecting a Multi-Wallet Crypto App in Flutter

Clean architecture, secure key storage, HD wallets, multi-chain abstraction and WalletConnect: how to structure a production multi-wallet crypto app in Flutter.

CoodesCoodes Engineering Team 12 min read · 2,538 words
Architecting a Multi-Wallet Crypto App in Flutter
Table of contents

A crypto wallet is one of the most unforgiving apps you can ship. A layout bug in a to-do app costs you a one-star review. The same kind of bug in a wallet can send funds to the wrong address, leak a seed phrase, or leave users locked out of their own assets. Once you support several chains, several accounts and external dApp connections, "one more feature" quickly turns into a tangle of shared state, async calls and security-critical code paths.

This guide goes through an architecture that holds up in production for a multi-wallet, multi-chain Flutter app. We'll cover layering, state management, key storage, HD derivation, chain abstraction, RPC handling, WalletConnect, the transaction signing flow, biometrics, testing and app-store review. The Dart snippets are deliberately small. They show the shape of each piece, and you should adapt them to your own codebase and security review.

Why architecture matters more in wallet apps

Most Flutter apps can get away with a loose structure for a while. Wallets can't, for three reasons:

  • Security boundaries have to be explicit. The code that touches private keys should be small, easy to audit and isolated from UI code. If a widget can reach a mnemonic string, sooner or later something will log it.
  • Every chain behaves differently. EVM chains, Solana, Bitcoin and Cosmos-based networks each have their own address formats, fee models, nonce or sequence rules and signing schemes. Without an abstraction, if (chain == ...) branches end up everywhere.
  • State is distributed and eventually consistent. Balances, pending transactions, token prices and dApp sessions all update on their own schedules, often from unreliable network sources.

So the goal is a structure where adding a chain, swapping an RPC provider or changing the state library is a local change, and where the key-handling code is short enough to read carefully in one sitting.

A layered, clean architecture

We recommend a pragmatic take on clean architecture with four layers. Dependencies point inward, so the domain layer knows nothing about Flutter, HTTP or storage plugins.

LayerResponsibilityTypical contents
PresentationWidgets, routing, view stateScreens, controllers/notifiers, formatters
ApplicationUse cases that orchestrate domain logicSendTransaction, ImportWallet, ApproveDappRequest
DomainPure business rules and contractsEntities (Account, Asset, TxRequest), repository interfaces, ChainAdapter
InfrastructureConcrete I/ORPC clients, secure storage, WalletConnect client, price APIs, local database

A feature-first folder layout usually scales better than one grouped by layer, because related code stays together:

lib/
  core/            # errors, result types, logging, DI setup
  vault/           # key management (small, heavily reviewed)
  chains/
    evm/
    solana/
    chain_adapter.dart
  features/
    accounts/
    send/
    receive/
    dapp_connect/
    settings/
  app.dart

Keep vault/ as its own top-level module with a very small public API. The rest of the app should never import its internals.

State management: Riverpod vs Bloc

Both Riverpod and Bloc work well for wallets. Which one to pick depends mostly on team preference and how much ceremony you want.

Riverpod

Riverpod's providers double as a dependency-injection graph. That's handy when you want to swap a real RPC client for a fake one in tests. AsyncNotifier and FutureProvider map naturally onto network-backed data such as balances, and family providers make per-account or per-chain state easy to express.

final balanceProvider = FutureProvider.autoDispose
    .family<BigInt, ({ChainId chain, String address})>((ref, args) async {
  final adapter = ref.watch(chainAdapterProvider(args.chain));
  return adapter.getNativeBalance(args.address);
});

Bloc

Bloc's explicit event-to-state model works well for multi-step flows such as sending a transaction (compose, estimate, review, authenticate, broadcast, confirm), where you want every transition visible and testable. Teams that value strict, reviewable state machines tend to prefer it.

In practice many teams mix the two: Riverpod for dependency wiring and simple async data, and a Bloc or a hand-rolled state machine for the send and dApp-approval flows. Whatever you choose, never keep secrets in UI state. State objects get logged, serialized for debugging and captured by devtools.

Tip: Model the send flow as a sealed class hierarchy (Composing, Estimating, AwaitingAuth, Broadcasting, Submitted, Failed). Dart 3's exhaustive switch then forces the UI to handle every state, including the error cases people tend to forget.

Key management and secure storage

This part matters more than anything else in the app. A few principles:

  1. Generate entropy with a cryptographically secure RNG. Use Random.secure() or a vetted native library, never Random().
  2. Encrypt at rest using platform keystores. On iOS that means the Keychain, with Secure Enclave-backed keys where possible. On Android it means the Android Keystore, ideally hardware-backed or StrongBox where the device has it.
  3. Minimise how long secrets live in memory. Decrypt only when signing, and drop references straight afterwards. Dart doesn't let you reliably zero memory, but using byte lists instead of strings avoids leaving immutable copies scattered across the heap.
  4. Never log, screenshot or back up secrets by accident. Turn off cloud backup for sensitive storage and block screenshots on seed-phrase screens.

flutter_secure_storage is the usual starting point. It wraps the Keychain on iOS and Keystore-backed encryption on Android. Configure it deliberately rather than relying on defaults:

const storage = FlutterSecureStorage(
  iOptions: IOSOptions(
    accessibility: KeychainAccessibility.first_unlock_this_device,
  ),
  aOptions: AndroidOptions(
    encryptedSharedPreferences: true,
  ),
);

The _this_device accessibility classes stop Keychain items from migrating to a new device through encrypted backups. For a wallet that's usually what you want, since users should restore from their recovery phrase. Plugin options change between major versions, so check the current package documentation before copying configuration.

Envelope encryption

A common and robust pattern is envelope encryption. Generate a random data-encryption key (DEK), use it to encrypt the mnemonic with an authenticated cipher such as AES-GCM, and store the DEK in the platform keystore, optionally gated by user authentication. The encrypted mnemonic can then sit in ordinary app storage, and access depends on the hardware-protected key.

abstract interface class Vault {
  Future<void> createFromMnemonic(Uint8List entropy, {required String walletId});
  Future<Uint8List> signDigest({
    required String walletId,
    required DerivationPath path,
    required Uint8List digest,
  });
  Future<void> wipe(String walletId);
}

Look at what the interface leaves out: there is no getPrivateKey() and no getMnemonic(), apart from a dedicated, re-authenticated "reveal recovery phrase" flow. The vault signs things, and keys never leave it.

Warning: Don't write your own cryptographic primitives. Use well-maintained libraries for secp256k1, ed25519, BIP-39 and AES-GCM, pin their versions, and review changelogs before you upgrade. Get an independent security review of your key-handling module before launch.

HD wallets: BIP-39, BIP-32 and BIP-44

A multi-wallet app normally lets users manage several wallets (distinct seeds, some imported) and several accounts within each wallet. Hierarchical deterministic (HD) derivation makes this manageable:

  • BIP-39 turns entropy (128–256 bits) into a 12–24 word mnemonic and derives a seed from it, with an optional passphrase.
  • BIP-32 defines how child keys are derived from that seed.
  • BIP-44 sets a path convention: m / purpose' / coin_type' / account' / change / address_index. Ethereum and most EVM chains use coin type 60, so the first account is usually m/44'/60'/0'/0/0.

Some ecosystems use different curves or conventions. Solana wallets commonly use ed25519 with SLIP-0010 and hardened-only paths such as m/44'/501'/0'/0', and Bitcoin wallets typically use BIP-84 for native SegWit. Keep derivation paths in each chain adapter rather than hard-coding them globally, and store the path alongside each account so imports from other wallets resolve to the same addresses.

class DerivationPath {
  final List<int> segments; // hardened segments have the 0x80000000 bit set
  const DerivationPath(this.segments);

  static DerivationPath evm(int index) => DerivationPath([
        44 | 0x80000000,
        60 | 0x80000000,
        0 | 0x80000000,
        0,
        index,
      ]);
}

When importing, let advanced users choose a path, and scan a few indexes for existing balances. Users coming from other wallets often find their "missing" funds sitting on a different path.

Multi-chain abstraction and RPC providers

The ChainAdapter interface is what keeps chain-specific logic out of your features. The send screen shouldn't care whether it's building an EIP-1559 transaction or a Solana message.

abstract interface class ChainAdapter {
  ChainInfo get info; // id, name, symbol, decimals, explorer URL
  DerivationPath defaultPath(int accountIndex);
  String addressFromPublicKey(Uint8List publicKey);
  bool isValidAddress(String input);

  Future<BigInt> getNativeBalance(String address);
  Future<FeeQuote> estimateFee(TxRequest request);
  Future<UnsignedTx> buildTransaction(TxRequest request, FeeQuote fee);
  Uint8List signingPayload(UnsignedTx tx);
  Future<String> broadcast(UnsignedTx tx, Uint8List signature);
  Stream<TxStatus> watch(String txHash);
}

A single EvmChainAdapter, configured with a chain ID and RPC endpoints, covers Ethereum, Polygon, Arbitrum, Optimism, Base, BNB Chain and most other EVM networks. Non-EVM chains each get their own adapter.

RPC reliability

Public RPC endpoints get rate-limited and go down. In production:

  • Configure at least two providers per chain and fail over on errors or timeouts.
  • Keep API keys out of the binary where you can. A thin backend proxy lets you rotate keys, add caching and apply rate limits. Anything shipped in a mobile app can be extracted.
  • Cache read-only data such as token metadata and prices, and show "last updated" timestamps so users know how fresh balances are.
  • Treat RPC responses as untrusted input. Validate chain IDs and don't let a misconfigured endpoint quietly switch networks.
Design every network call as if it will fail, return stale data or arrive out of order, because on mobile networks all three happen regularly.

WalletConnect v2 (Reown) and the signing flow

WalletConnect v2, now maintained under the Reown brand, lets your wallet connect to dApps across the web through QR codes or deep links. Reown publishes Flutter SDKs for the wallet side (WalletKit). The core ideas are:

  • Pairing and sessions: a dApp proposes a session listing the required and optional namespaces (for example eip155:1, eip155:137), methods (eth_sendTransaction, personal_sign, eth_signTypedData_v4) and events.
  • Approval: your wallet shows the dApp's metadata and the requested permissions, and the user approves specific accounts and chains.
  • Requests: signing requests come in as session requests that your app must route to a review screen.

Handle session proposals as untrusted input. Show the verified domain where the SDK provides verification information, warn loudly on mismatches, and never auto-approve.

A safe transaction signing pipeline

Whether a transaction starts in your own send screen or comes from a dApp, run it through the same pipeline:

  1. Normalize the request into a domain TxRequest.
  2. Validate the addresses, the chain ID against the active session, value limits and data size.
  3. Simulate or decode where you can: decode known function selectors (ERC-20 approve, transfer), flag unlimited approvals, and show human-readable amounts with the correct decimals.
  4. Estimate fees and show them in both native units and fiat.
  5. Authenticate the user with biometrics or a PIN immediately before signing.
  6. Sign inside the vault and pass back only the signature.
  7. Broadcast and watch, persisting the pending transaction so it survives an app restart.
Future<Result<String>> send(TxRequest req) async {
  final adapter = adapters.of(req.chain);
  final fee = await adapter.estimateFee(req);
  final unsigned = await adapter.buildTransaction(req, fee);

  final approved = await reviewGate.confirm(unsigned, fee); // UI review screen
  if (!approved) return Result.cancelled();

  final authed = await auth.require(reason: 'Confirm transaction');
  if (!authed) return Result.cancelled();

  final sig = await vault.signDigest(
    walletId: req.walletId,
    path: req.path,
    digest: adapter.signingPayload(unsigned),
  );
  final hash = await adapter.broadcast(unsigned, sig);
  await pendingTxRepo.save(req.chain, hash);
  return Result.ok(hash);
}

Typed-data signatures (EIP-712) and "blind" message signing need the same care. Show the structured contents, and treat permit-style signatures as sensitive as token approvals, because that's effectively what they are.

Biometric authentication and security UX

The local_auth plugin exposes Face ID, Touch ID and Android BiometricPrompt. On its own, though, a biometric check in Dart is just a boolean that a compromised device could bypass. The stronger pattern binds the keystore key itself to user authentication, so the DEK literally can't be used without a successful biometric or device-credential check. Depending on the plugin versions you use, that may mean platform-channel code or a package that supports authentication-bound keys.

Good security UX habits include:

  • An app-level PIN as a fallback, with rate limiting and an optional wipe after repeated failures.
  • An auto-lock timeout, plus blurring the app switcher snapshot when the app goes to the background.
  • A backup flow that makes users confirm several words of the recovery phrase before they can fund the wallet.
  • Clear warnings about clipboard use, and clearing copied addresses after a short delay when that fits your UX.
  • Address book entries and first-time-recipient warnings, to reduce the risk from address-poisoning attacks.

Our MultiWallets theme includes ready-made screens for wallet lists, account switching, send/receive and portfolio views, so your team can spend its time on the security-critical internals instead of rebuilding common layouts. If you're building a broader crypto or NFT experience, the FlutterSee theme is a solid Flutter UI foundation to start from.

Testing strategy and app-store compliance

Testing layers

  • Unit tests for derivation and signing, checked against published test vectors. BIP-39 and BIP-32 both have official vectors, and your code should reproduce them exactly.
  • Adapter tests against local nodes. Anvil or Hardhat for EVM, and local validators for other chains where they exist. Never run automated tests against mainnet.
  • Bloc/notifier tests covering every state transition in the send and approval flows, including timeouts and user cancellation.
  • Widget and golden tests for the review screen, since that's where users make decisions with real money.
  • Integration tests using integration_test on real devices to exercise secure storage and biometrics, which emulators only partly simulate.
test('derives the expected first EVM address from a known test mnemonic', () async {
  final vault = InMemoryTestVault.fromMnemonic(testMnemonic);
  final pub = await vault.publicKey(DerivationPath.evm(0));
  expect(evm.addressFromPublicKey(pub).toLowerCase(), expectedAddress);
});
Note: Keep test mnemonics clearly labelled and fenced off from release builds. A lint rule or CI check that fails the build if a known test phrase appears outside test/ is cheap insurance.

App-store considerations

Apple and Google both have specific policies for cryptocurrency apps, and they change over time, so read the current guidelines before you submit. In general, expect reviewers to look at:

  • Whether the app is a non-custodial wallet (usually more straightforward) or offers exchange, trading or custodial services, which may require the developer account to belong to an appropriately licensed organization in the relevant regions.
  • How in-app purchases relate to digital goods and NFTs, and whether any feature unlocks content in a way that should go through platform billing.
  • Accurate privacy disclosures (App Store privacy labels, Google Play Data safety) covering analytics, crash reporting and any wallet addresses you send to a backend.
  • A clear account and data deletion path if you have user accounts.
  • Demo credentials or a test mode so reviewers can use the app without funding a wallet.

It also helps to have obfuscation (--obfuscate --split-debug-info), root/jailbreak detection used as a warning rather than a guarantee, and certificate pinning for your own backend, as long as you have a rotation plan.

Conclusion

A solid multi-wallet Flutter app comes down to a few disciplined choices. Keep the domain layer pure. Put all key handling behind a vault that signs but never exports. Hide chain differences behind adapters. Send every transaction, local or from a dApp, through one reviewed signing pipeline. Test against official vectors and local nodes. Handle all of that well and the UI becomes the easy part, especially if you start from a production-ready theme like MultiWallets or FlutterSee.

If you want experienced hands on the architecture, the vault implementation or WalletConnect integration, take a look at our Flutter mobile app development service or get in touch to talk through your roadmap.

Written by the Coodes Engineering Team

We build premium Web2 & Web3 themes and help teams ship dApps, SaaS platforms and Flutter apps.

Talk to us

Related articles

Ready to Build Something Amazing?

Join thousands of developers who trust our themes for their projects. Professional designs, clean code, and ongoing support.