A self-built clone of the Solflare wallet, written from scratch in Flutter. Built solo as a deep-dive into wallet security, Solana RPC, and the mobile patterns a self-custody app has to get right.
Where it's strong: the security core. Centralized Keyring primitive for every signing call site, versioned PBKDF2 envelope for passcode storage with silent legacy-plaintext migration (ADR 0002), FlutterSecureStorage explicitly configured with KeychainAccessibility.first_unlock_this_device, intermediate-buffer scrubbing after key derivation, post-send polling fallback for flaky WebSockets, in-flight wallet-switch guards so stale balance responses can't pollute the new account's UI, lifecycle-aware WebSocket with exponential backoff, an iOS-side MethodChannel that paints a privacy overlay on the app-switcher snapshot, a biometric unlock whose Keychain item is bound to biometryCurrentSet so enrolling a new face invalidates it, and a transaction preview that decodes and risk-checks every instruction before anything is signed. 438 tests.
Where it's not: this is a Flutter-only project. There is no advanced native iOS or Android code beyond the screenshot-blocking and deep-link MethodChannels — no Secure Enclave integration, no hardware wallet support, no SSL pinning, no Universal Link or Mobile Wallet Adapter wiring (solfare:// deep links are wired; the OS-verified ones are not). Android release still signs with debug keys. PBKDF2 iteration count is 100k, below the 2025 OWASP recommendation of ~600k (the version envelope is set up for an upgrade). And as a solo project, it has no shipped-to-production scar tissue — only what I learned reverse-engineering Solflare.
A wallet is judged on the parts a user never sees: what a transaction will do before it's signed, which key is in memory and when it's wiped, what the balance says when the socket drops mid wallet-switch. That's where this repo spends its effort, and it's what I'd want to be measured on — designing for the failure case first, writing the design down before the code, and shipping the test that would have caught the bug. The limits above are listed on purpose; I'd rather be read accurately than impressively.
- Wallet onboarding — BIP-39 mnemonic generation or import, with confirm-phrase verification and biometric / passcode setup.
- Multi-wallet — create, rename, switch, and export multiple wallets. Lazy migration from the pre-multi-wallet single-mnemonic storage format.
- Send & receive SOL — address validation, QR scanning, on-device signing, live balance updates over WebSocket, polling fallback if the WS drops.
- SPL tokens & NFTs — Helius DAS for both, with local caching that survives cold restart.
- Swap — Jupiter v2 (
/order+/execute) with v0-VersionedTransaction signing on-device. - Staking — single bundled
createAccount + initialize + delegatetransaction so a partial land doesn't leave orphan stake accounts. - Market data — CoinGecko-backed prices and charts via a singleton client with serialised queue, in-flight coalescing, and stale-cache fallback on 429.
- Transaction preview — every instruction decoded against a program registry and run through a risk engine, so the approval sheet says what will actually move rather than showing a blob of base64.
- Biometric unlock — Face ID releases the wrap key from the Keychain itself; the OS is the gate, not a boolean in Dart.
- In-app dApp browser with
solfare://deep-link requests and a connected-apps list, address book, l10n, light/dark themes, configurable RPC (Mainnet / Devnet).
| Surface | How it's handled |
|---|---|
| Mnemonics + private keys at rest | FlutterSecureStorage (iOS Keychain / Android Keystore) with KeychainAccessibility.first_unlock_this_device |
| Passcode storage | Versioned envelope v1:salt:iter:hash over PBKDF2-HMAC-SHA256; constant-time compare; silent legacy-plaintext migration — see ADR 0002 |
| Key derivation | Single Keyring primitive — every signing path goes through one place — see ADR 0001 |
| Intermediate seed + privkey bytes | try/finally zeroing inside Keyring; ExportPrivateKeyScreen scrubs displayed key bytes on dispose |
| Screenshot / app-switcher leak | Android FLAG_SECURE; iOS routes through a MethodChannel to a Swift handler that paints a privacy overlay on willResignActive |
| Clipboard | SecureClipboard.copySensitive auto-clears after 30s only if the value hasn't changed |
| Biometric unlock | Wrap key stored under KeychainAccessibility.passcode + AccessControlFlag.biometryCurrentSet — the OS releases it only after authenticating the holder, and a newly enrolled face or finger invalidates it |
| Brute-force | 5-attempt lockout in PasscodeBloc with backoff |
| Fresh-install hygiene | _wipeSecureStorageOnFreshInstall clears stranded Keychain entries on first run after install |
Feature-sliced Clean Architecture. wallet is the canonical example with full data / domain / presentation layering; swap and staking are flatter because they're thinner features and the layering hadn't earned its keep there yet — that inconsistency is honest, not aspirational.
lib/
├── core/
│ ├── network/ # http_retry, coingecko_client
│ ├── security/ # passcode_crypto, secure_store, secure_screen, secure_clipboard
│ ├── wallet/ # keyring (single signing primitive), active_wallet
│ └── ...
├── features/
│ ├── wallet/ # data / domain / presentation (full Clean layers)
│ ├── swap/ # data / presentation (flatter)
│ ├── staking/ # presentation only
│ ├── market/ ...
├── shared/ # splash, onboarding, reusable widgets
└── l10n/
ios/Runner/
└── AppDelegate.swift # MethodChannels: app-switcher privacy overlay, solfare:// deep links
test/
├── core/security/ # passcode envelope + migration, mnemonic envelope, biometric lock, app lock
├── core/wallet/ # keyring (BIP-44 determinism, invariants, secret parsing)
├── core/solana/ # instruction decoder, risk engine, recipient check, dApp sessions, Solana Pay
├── core/network/ # http_retry (retry-then-succeed, timeout, max-attempts), friendly error mapping
├── core/currency/ # money formatting across 20 currencies
└── features/ # wallet, swap, market, staking, settings, homepage
docs/
├── adr/ # architecture decision records
└── learning/ # personal build-journal markdowns (gitignored from main tracking)
438 tests across 43 files, from the passcode envelope out to swap pair selection and the swap screen's height budget. Storage tests fake FlutterSecureStorage via TestDefaultBinaryMessenger so they run without a device or emulator. Run with flutter test.
Flutter / Dart, flutter_bloc, go_router, solana, bip39, ed25519_hd_key, bs58, crypto (PBKDF2-HMAC-SHA256), flutter_secure_storage, flutter_windowmanager, http, web_socket_channel, webview_flutter, fl_chart, lottie, flutter_svg, qr_flutter, qr_code_dart_scan. Custom FKGrotesk typography.
flutter pub get
flutter run \
--dart-define=HELIUS_API_KEY=your_helius_key \
--dart-define=JUPITER_API_KEY=your_jupiter_keyAPI keys are passed per build rather than bundled. .env used to be listed
under flutter: assets:, which put it inside the IPA and the APK as a plain
file — unzip -p Solfare.ipa 'Payload/*/flutter_assets/.env' returned the key
from any user's copy. .gitignore kept it out of the repository, which is
correct and also beside the point.
A --dart-define is compiled into the binary and still extractable by anyone
determined enough; nothing shipped to a device is a secret. What it buys is a
key that is per-build, so it can be rotated and scoped without a code change,
and a debug key that cannot silently become the production one. Without them
the app runs and Helius-backed features (tokens, NFTs, live balances) come back
empty.
Mainnet by default; Devnet selectable from Settings → Network.
To run the test suite:
flutter testI've been documenting this build on X — short demo clips:
Longer-form build journal lives at github.com/frankolien/solflare-clone-guide.
Flutter developer focused on mobile apps that touch crypto, security, and real-time data. Open to roles — Frankolien123@gmail.com · @frank_olien123.