Building Anvil Wallet: Why I Put the Crypto Core in Rust
When I started building Anvil Wallet, I kept coming back to one uncomfortable question: after a mobile wallet signs a transaction, what happens to the private key in memory?
In a garbage-collected runtime, cleanup happens when the runtime decides it should. That is perfectly reasonable for most app data. It is less comforting when the data is a seed phrase that controls real money.
So I drew a hard boundary. Swift would run the iPhone experience; Rust would handle anything that could expose a key.
The whole project is open source: github.com/mohitsharmadl/anvil-wallet.
One Wallet, Two Very Different Jobs
The iOS app and the wallet core have separate responsibilities:
- Swift owns the phone: screens, Face ID, QR scanning, Secure Enclave access and Keychain storage.
- Rust owns the money: seed generation, key derivation, signing, encryption and transaction construction.
UniFFI sits between them and generates type-safe Swift bindings for the Rust API. The boundary is deliberately narrow. Swift asks for an operation; Rust performs it and returns only what the interface needs.
One small, typed boundary keeps crypto logic out of UI code.
AES · Argon2id · shared primitives
BIP-39 · HD keys · orchestration
Bitcoin transactions
EVM networks · Solana
That split has also made the code easier to reason about. The Rust core is five small crates rather than one large crypto module. Bitcoin code does not need to know how Solana works, and the encryption utilities can be tested without booting an iPhone simulator.
Why Rust Earned Its Place
Rust was not a branding choice. It solved three practical problems I cared about.
I can decide when secrets leave memory
Sensitive types use the zeroize crate and derive ZeroizeOnDrop. When a value leaves scope, its bytes are overwritten as part of Drop; cleanup is tied to the lifetime of the value rather than a future garbage-collection pass.
#[derive(ZeroizeOnDrop)]
struct DerivedKey {
private_key: Vec<u8>,
chain_code: Vec<u8>,
}
That small annotation is not the whole security model, but it gives the code a clear rule: secret material gets a type, a lifetime and an explicit end.
The crypto stays in Rust
Anvil uses k256 for secp256k1 instead of calling a C library through another FFI boundary. It removes a build step, makes iOS cross-compilation less painful and keeps the sensitive path inside one language and toolchain.
The core is not tied to iOS
Today, Swift calls the Rust core. An Android client can call the same core later. The chain rules, signing code and test vectors do not need to be translated into Kotlin and then kept in sync forever.
Ten Chains Without One Giant Dependency Tree
Anvil currently supports Bitcoin, Ethereum, seven other EVM networks and Solana.
| Network | Implementation |
|---|---|
| Bitcoin | rust-bitcoin 0.32 for P2WPKH addresses, UTXO selection and SegWit signing |
| Ethereum + 7 EVM chains | Focused alloy crates for EIP-1559 transactions, ERC-20 support and fee estimation |
| Solana | Roughly 400 lines implementing the wire format directly |
Solana was the unusual decision. Pulling in solana-sdk meant bringing Tokio and more than 200 transitive dependencies into an iOS build. The transaction format itself is compact: accounts, instructions and Ed25519 signatures. Implementing that narrow surface directly was easier to audit than carrying an SDK designed for a much larger environment.
Each network lives in its own crate. chain-btc does not import Ethereum logic; chain-sol does not import Bitcoin logic. A change to one chain is less likely to disturb another.
What “16 Security Layers” Actually Means
The number matters less than the failure paths it covers. I keep a list of 16 distinct controls so that “secure” does not become a vague promise.
3 hardware + 4 encryption + 6 device + 3 transaction/privacy.
- Secure Enclave key
- Device-only Keychain
- Biometric transaction gate
- AES-256-GCM
- Argon2id KDF
- Memory zeroization
- BIP-39 mnemonic
- Jailbreak detection
- Anti-debugging
- Screen protection
- Clipboard clearing
- Certificate pinning
- Binary integrity
- Pre-sign simulation
- Address validation
- Zero telemetry
The controls overlap on purpose. A stolen phone still meets Face ID and a password. A copied Keychain record is still encrypted. A malicious RPC response is checked before signing. If one defence fails, it should not expose the seed by itself.
There was also a counting bug in the old diagram: it treated “zero telemetry” and “zero analytics” as separate layers. They are the same privacy decision. The corrected model has 3 hardware controls, 4 encryption controls, 6 device defences and 3 transaction/privacy controls — 16 in total.
Why the Seed Is Encrypted Twice
Apple's Secure Enclave supports P-256 keys. Bitcoin and Ethereum use secp256k1. That means a blockchain private key cannot simply be placed inside the Enclave.
Instead, Anvil uses the Enclave to protect an already encrypted seed:
A password alone is not enough. A copied Keychain blob is not enough.
Argon2id derives the first encryption key.
The derived key encrypts the seed.
A device-bound P-256 key wraps the encrypted value.
Device only · no iCloud backup · biometric gate.
- The password goes through Argon2id using 64 MB of memory, three iterations and four lanes.
- The derived key encrypts the seed with AES-256-GCM.
- The Secure Enclave's device-bound P-256 key wraps that encrypted value using ECIES.
- The result is stored in the Keychain as device-only data, excluded from iCloud sync and gated by biometrics.
Opening the wallet therefore requires both sides: something the user knows and a hardware-backed key that cannot be exported from the phone.
I chose 64 MB for Argon2id after testing on current iPhones. A desktop can afford a much larger memory cost, but a mobile wallet cannot freeze for several seconds every time it unlocks. At this setting, a modern iPhone takes about a second while every password guess still carries a meaningful memory cost.
The Parts That Fought Back
Most of the useful lessons came from details I initially expected to be boring.
UniFFI wants owned values
UniFFI 0.28 passes String and Vec<u8> across the boundary, not borrowed &str and &[u8]. Exported functions accept owned values and borrow them internally. I lost time to “expected &str, found String” errors before making that rule consistent.
Documentation can lag a crate release
With bip39 2.2, Mnemonic::generate_in() was not available in the API I was compiling against. The working path was to generate 32 bytes with OsRng, call Mnemonic::from_entropy_in(), and use parse_in_normalized() for imports.
Drop changes how values can move
DerivedKey implements ZeroizeOnDrop, so it also implements Drop. Rust will not let code move individual fields out of it. Non-sensitive fields such as derivation_path need to be cloned before the value drops.
Swift copy-on-write is easy to misunderstand
I originally tried to clear a stored ContiguousArray<UInt8> through a temporary variable:
if var bytes = sessionPasswordBytes {
bytes[i] = 0
}
That clears the copy, not the stored buffer. The fix was to mutate sessionPasswordBytes directly. It was a useful reminder that “I wrote zeros” and “I cleared the original bytes” are not always the same statement.
Certificate pins need a rotation plan
Pinning only a leaf certificate is a good way to break every network request when that certificate renews. Anvil stores a second pin for the intermediate CA and fails closed for the hosts it pins. The extraction script also rejects the SHA-256 hash of empty input, which OpenSSL can otherwise produce quietly when certificate extraction fails.
Test vectors belong in the first commit
The test suite uses official BIP-39 vectors, EIP-55 checksum cases and known Bitcoin addresses. A cryptographic function returning a plausible-looking value is not evidence that it is correct. A published vector is.
Where the Project Stands
| Measure | Current count |
|---|---|
| Rust tests | 241 |
| Rust crates | 5 |
| SwiftUI views | 46 |
| Documented security controls | 16 |
| Analytics SDKs | 0 |
| Third-party data collection | None |
| Approximate lines of code | 17,000+ |
Why the Repository Is Public
A wallet should not ask people to trust claims they cannot inspect.
Publishing the code does not make AES weaker or reveal a secret shortcut through Argon2id. It does let other developers check the derivation paths, inspect the network policy, question the threat model and point out mistakes.
That is the bargain I want: less reliance on marketing language, more reliance on code that can be read.