Building Anvil Wallet: Why I Wrote a Crypto Wallet in Rust
Most mobile crypto wallets are built in JavaScript or Swift/Kotlin. The crypto operations — generating keys, signing transactions, encrypting seeds — all happen in a language with a garbage collector that might leave your private keys floating around in memory.
That didn't sit right with me. So I built Anvil Wallet — a self-custody crypto wallet where every line of crypto logic is written in Rust.
It's fully open source: github.com/mohitsharmadl/anvil-wallet
The Core Idea
Split the wallet into two layers:
- Rust handles everything sensitive — key generation, transaction signing, seed encryption, address derivation
- Swift handles everything visual — Face ID, QR codes, UI screens, Secure Enclave
The two layers talk through UniFFI, Mozilla's FFI generator that auto-creates type-safe Swift bindings from Rust code.

- Services: Secure Enclave (P-256 hardware key), iOS Keychain, Face ID/Touch ID
- Features: 46 SwiftUI views covering onboarding, wallet, send, DApps, activity, settings
- Rust Core: 5 independent crates, each testable in isolation, 241 tests total
Why Rust for a Crypto Wallet?
Three reasons.
1. Deterministic memory cleanup. Rust's zeroize crate guarantees private keys are wiped from memory the moment they go out of scope. No garbage collector deciding when to clean up. No secrets lingering in memory for an attacker to dump.
#[derive(ZeroizeOnDrop)]
struct DerivedKey {
private_key: Vec<u8>,
chain_code: Vec<u8>,
}
// When this struct drops, private_key and chain_code
// are overwritten with zeros. Guaranteed.
2. Pure Rust crypto. We use k256 (pure Rust secp256k1) instead of C bindings like secp256k1-sys. No C compilation step, no cross-compilation headaches for iOS. The RustCrypto ecosystem gives us audited, battle-tested implementations.
3. Cross-platform for free. The same Rust code compiles for iOS today and Android tomorrow. No rewriting crypto logic in Kotlin.
Multi-Chain Without the Bloat
Anvil supports 10 chains out of the box:
| Chain | How |
|---|---|
| Bitcoin | rust-bitcoin 0.32 — P2WPKH addresses, UTXO coin selection, SegWit signing. Full send flow via Rust FFI + Blockstream API |
| Ethereum + 7 EVM chains | alloy sub-crates — EIP-1559 transactions with eth_feeHistory-based fee estimation, ERC-20 tokens, checked arithmetic to guard against adversarial RPC values |
| Solana | Manual wire format in ~400 lines. No solana-sdk. |
The Solana decision deserves explanation. The solana-sdk crate pulls in tokio, 200+ dependencies, and has iOS cross-compilation issues. But Solana's transaction format is actually simple — a compact binary format with accounts, instructions, and Ed25519 signatures. We implemented it manually, same approach as Gem Wallet (a production wallet).
Each chain is an independent Rust crate. chain-btc knows nothing about Ethereum. chain-sol knows nothing about Bitcoin. They can be tested, audited, and updated independently.
16 Security Layers
Most wallets encrypt your seed and call it a day. Anvil implements 16 layers of defense-in-depth:

- Hardware: Secure Enclave P-256 key, iOS Keychain (device-only, no iCloud), biometric gating
- Encryption: AES-256-GCM (NCC Group audited), Argon2id KDF (PHC winner), memory zeroization, BIP-39 mnemonic with optional passphrase
- Protection: 6-layer jailbreak detection, anti-debug (ptrace + sysctl), screenshot blocking, clipboard auto-clear, fail-closed certificate pinning (dual SPKI pins per host — leaf + intermediate CA), app binary integrity (fail-closed in Release)
- Privacy: Transaction simulation before signing, address poisoning detection, HTTPS-only RPC enforcement, zero telemetry, zero analytics
The Double Encryption Problem
Here's a constraint most people don't know about: Apple's Secure Enclave only supports P-256 elliptic curves. Bitcoin and Ethereum use secp256k1. You literally cannot store blockchain private keys in the Secure Enclave.
The solution: encrypt the seed twice.

- Rust layer: User's password goes through Argon2id (64MB memory, 3 iterations, 4 threads) to derive an AES-256-GCM key. The seed is encrypted.
- Hardware layer: The encrypted seed is encrypted again by the Secure Enclave's P-256 key using ECIES.
- Storage: The double-encrypted blob goes into the iOS Keychain with
kSecAttrAccessibleWhenUnlockedThisDeviceOnly— no iCloud, no backups, biometric-gated.
An attacker needs: your physical device + your face + your password + a Secure Enclave exploit. All four simultaneously.
Why 64MB for Argon2id instead of the desktop standard of 1GB? Because we're on an iPhone. 64MB takes about 1 second on an iPhone 14+ and still forces attackers to allocate 64MB per password guess — making GPU-based brute force impractical.
Lessons From Building This
UniFFI passes owned types, not references. UniFFI 0.28 generates Swift bindings that pass String and Vec<u8> across the FFI boundary, not &str or &[u8]. Every exported function needs to accept owned types and borrow internally. We spent time debugging "expected &str, found String" errors before understanding this.
bip39 2.2 changed its API. The Mnemonic::generate_in() function doesn't exist despite what some docs say. You need Mnemonic::from_entropy_in() with your own 32 bytes of entropy from OsRng, and parse_in_normalized() instead of parse_in().
You can't move out of Drop types. Our DerivedKey implements ZeroizeOnDrop, which means it implements Drop. Rust won't let you move fields out of a type that implements Drop. The fix: .clone() non-sensitive fields like derivation_path before the struct drops.
Test with real vectors from day one. We test against official BIP-39 test vectors, known Bitcoin addresses (private key 0x01 produces bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4), and EIP-55 checksum examples. 241 tests catch regressions before they become lost funds.
Swift copy-on-write will betray you. We stored the session password as ContiguousArray<UInt8> for zeroization. But if var bytes = sessionPasswordBytes { bytes[i] = 0 } creates a copy — Swift's value types use COW semantics. You zero the copy, the original stays in memory. The fix: mutate the stored property directly with sessionPasswordBytes![i] = 0.
Certificate pinning needs two pins per host or you'll brick yourself. Leaf certificates rotate frequently. If you pin only the leaf and it rotates before you push an app update, every user's RPC calls fail. Pin the intermediate CA as a backup — those rotate much less often. Also: your pin extraction script should reject SHA-256 of empty input (47DEQpj8...), which is what openssl silently produces when cert extraction fails.
The Numbers
| Metric | Value |
|---|---|
| Rust tests | 241 (all passing) |
| Rust crates | 5 |
| Swift files | 46 |
| Security layers | 16 |
| Analytics SDKs | 0 |
| Third-party data collection | None |
| Lines of code | 17,000+ |
Open Source
Every line of Anvil Wallet's code is public. You can read every encryption function, every key derivation path, every security check. This is intentional.
Security through obscurity doesn't work for a crypto wallet. If your encryption is strong, it doesn't matter that attackers can read the code. AES-256-GCM doesn't get weaker because you publish the implementation. Argon2id doesn't get weaker because attackers know the parameters.
What does get stronger: trust. Users can verify there's no backdoor. Security researchers can audit the code. The community can contribute fixes.
The repo: github.com/mohitsharmadl/anvil-wallet
The website: anvilwallet.com