Why I Built Anvil Wallet as a Modular Monolith
When I started building Anvil Wallet, a self-custody crypto wallet supporting Bitcoin, Ethereum, Solana, and Zcash, I had to make a core architectural decision early on. How do I structure a codebase where four completely different blockchains need to coexist, share cryptographic primitives, and ultimately compile into a single iOS app?
I chose a modular monolith. Here's what that means and why it was the right call.
What is a Modular Monolith?
A modular monolith is an architecture where all your code lives in a single deployable unit, but internally it's split into independent, well-bounded modules with strict interfaces between them.
Think of it as the middle ground between two extremes.

The classic monolith is one big codebase where everything knows about everything. Business logic, data access, and utilities all blend together. It works fine early on but becomes painful as complexity grows. Change one thing and you break three others because nothing has clear boundaries.
Microservices go the opposite direction. Every piece of functionality becomes its own independently deployed service with its own database, API, and deployment pipeline. This solves the coupling problem but introduces distributed systems complexity, network latency, versioning nightmares, and operational overhead.
A modular monolith takes the best of both. You get the clean boundaries and independent testability of microservices, but without the operational tax. Each module has its own types, its own tests, and its own responsibilities. They communicate through well-defined interfaces. But at the end of the day, everything compiles and ships together.
How Anvil Wallet is Structured
The Rust core of Anvil Wallet is organized as a Cargo workspace with six crates:
anvil-wallet/
crates/
crypto-utils/ # AES-256-GCM, Argon2id, zeroize wrappers
chain-btc/ # Bitcoin P2WPKH, UTXO transactions
chain-eth/ # Ethereum EIP-1559, ERC-20, 7 EVM chains
chain-sol/ # Solana Ed25519, SPL tokens
chain-zec/ # Zcash transparent v5 transactions
wallet-core/ # FFI boundary, BIP-39, HD derivation

Each chain crate is completely independent. chain-btc knows nothing about Ethereum. chain-sol has never heard of Zcash. They don't import each other. They don't share types. The only shared foundation is crypto-utils, which provides low-level cryptographic primitives that every chain needs.
Then wallet-core sits on top as the orchestration layer. It imports all four chain crates, adds mnemonic generation and HD derivation, and exposes everything to iOS through a single UniFFI boundary. The Swift app sees one clean interface with 32 functions. It doesn't know or care that behind that interface sit five independent Rust modules.
The iOS side follows a similar pattern. Features are organized into their own folders (Send, Swap, Bridge, Staking, DApps) with a services layer underneath. Each feature has its own view and view model. The services layer handles network calls, state management, and security.
Why This Architecture Works for a Crypto Wallet
Each blockchain is genuinely different

Bitcoin uses P2WPKH with UTXO-based transactions. Ethereum uses EIP-1559 with account-based nonces. Solana uses Ed25519 with a completely different transaction wire format. Zcash uses ZIP-225 v5 with Blake2b hashing.
These are not variations of the same thing. They are fundamentally different protocols with different cryptographic curves, different address formats, different fee models, and different transaction structures. Trying to force them into a shared abstraction would create a leaky mess.
With a modular monolith, each chain gets its own crate with its own types and its own logic. Bitcoin has UtxoData and sign_btc_transaction. Ethereum has sign_eth_transaction with EIP-1559 gas parameters. Solana has manual compact-u16 wire format encoding because I intentionally avoided pulling in the solana-sdk and its 200+ transitive dependencies.
Each crate is honest about what its chain actually needs. No forced abstractions.
Shared crypto primitives without version drift
All four chains need cryptographic operations. They all need secure random number generation. They all need key derivation. They all need to zeroize sensitive data from memory.
In a Cargo workspace, all crates share a single Cargo.lock. When I update k256 or sha2 or zeroize, every chain gets the same version. There's no scenario where Bitcoin is using one version of a crypto library while Ethereum uses another. For a wallet handling real money, this kind of consistency isn't optional.
The workspace-level dependency table in the root Cargo.toml makes this explicit:
[workspace.dependencies]
k256 = { version = "0.13", features = ["ecdsa"] }
sha2 = "0.10"
zeroize = { version = "1", features = ["derive"] }
Every crate that needs these references the workspace version. One source of truth.
Independent testing, unified release
I can test Bitcoin in isolation:
cargo test -p chain-btc # 40 tests
Or Ethereum:
cargo test -p chain-eth # 80 tests
Or everything together:
cargo test --workspace # 334 tests
When I'm working on Solana SPL token transfers, I don't need to think about Bitcoin UTXO selection. When I'm debugging Zcash v5 transaction serialization, Ethereum's ERC-20 encoding is irrelevant. Each module has its own test suite that runs fast and catches issues within its own boundary.
But when it's time to ship, everything compiles into a single libwallet_core.a static library, gets wrapped into an XCFramework, and links into the iOS app. One build script. One artifact. One app.
The FFI boundary demands a single interface

Anvil Wallet uses UniFFI 0.28 to bridge Rust and Swift. UniFFI generates Swift bindings from a UDL (interface definition) file. This means there's exactly one boundary between Rust and iOS.
This is where the modular monolith pays off. The wallet-core crate acts as the facade. It imports all chain crates, re-exports their functionality through a unified FFI interface, and handles cross-cutting concerns like mnemonic-to-seed conversion and HD path derivation.
The iOS app calls sign_btc_transaction() or sign_eth_transaction() through the same generated Swift module. It doesn't need separate frameworks for each chain. It doesn't need to manage multiple dynamic libraries. One static link, done.
If I had used microservices or separate libraries per chain, the iOS build would need to link against multiple frameworks, manage version compatibility between them, and deal with potential symbol conflicts. For a mobile app, that complexity buys you nothing.
Adding a new chain is straightforward
When I added Zcash support, the process was:
- Create
crates/chain-zec/with its ownCargo.toml - Implement address derivation and transaction signing
- Write tests (33 for Zcash)
- Add
chain-zecas a dependency inwallet-core - Add the new FFI functions to the UDL file
- Run
build-ios.shto regenerate Swift bindings
Nothing else changed. Bitcoin didn't need to be recompiled. Ethereum's tests didn't need to be re-run. The iOS app just got new functions available in the same FFI module.
Compare this to a monolithic codebase where adding a new chain means weaving new code into existing files, or a microservice architecture where it means deploying and maintaining a whole new service.
Security isolation comes naturally
In a crypto wallet, a bug in one module should not compromise another. If there's an edge case in Zcash's Blake2b hashing, it shouldn't be able to affect how Bitcoin signs transactions.
Module boundaries enforce this. Each chain crate has its own error types. chain-btc defines BtcError, chain-eth defines EthError. They can't accidentally catch or mishandle each other's errors. Memory containing private keys is zeroized within each module's scope. The crypto-utils crate provides the zeroize wrappers, but each chain crate manages its own sensitive data lifecycle.
When a Modular Monolith Makes Sense
This architecture works well when:
- You're building a single product, not a platform serving different teams
- Your modules have genuinely different internals but ship together
- You need a single deployment artifact, like a mobile app or CLI tool
- You want clean boundaries without distributed systems overhead
- Your team is small enough that independent deployment per module isn't necessary
It works less well when you need independent scaling, independent deployment cadences, or when different teams own different modules and need full autonomy.
For Anvil Wallet, there's no scaling concern because it's a local app. There's no independent deployment because everything ships as one binary. The modular monolith is the simplest architecture that gives me clean separation without unnecessary complexity.
The Bottom Line
Most wallet teams chose speed over structure. JavaScript was the fastest way to ship a browser extension. You could use ethers.js or web3.js and have a working wallet in weeks. Nobody was writing Rust crypto for a browser wallet when JS libraries existed. The tradeoff was security for speed-to-market.
Multi-chain wallets exist. Multi-chain wallets that implement every chain natively in memory-safe code without third-party SDKs touching private keys, that's what's rare. That's what Anvil is trying to be.
Architecture should serve the product, not the other way around. A crypto wallet needs strict module boundaries because each blockchain is fundamentally different. It needs a unified build because it ships as a single mobile app. It needs shared dependencies because cryptographic consistency is non-negotiable.
A modular monolith gives all three. No more, no less.
Anvil Wallet is fully open source: github.com/mohitsharmadl/anvil-wallet