Skip to content

Architecture

walletrs is a single-tenant Rust service that creates and operates Bitcoin wallets. It speaks gRPC (tonic) and HTTP/JSON (axum) against the same WalletService impl. The wallet primitives come from BDK 1.x and rust-miniscript 12; primary + time-locked recovery descriptor compilation comes from a vendored Liana fork.

External callers reach the service over either gRPC (default :50051) or HTTP/JSON (default :8080). Both surfaces share one WalletService impl: gRPC frames are decoded by tonic; HTTP requests are decoded by an axum router whose routes are codegen’d at build time from (google.api.http) annotations on the proto. The bearer-token gate sits in front of every RPC except Ping.

crates/server/src/
├── config.rs env-driven Config + global Lazy<Config>
├── db.rs thin (StorageManager, models) re-export layer
├── lib.rs crate root + public re-exports
├── main.rs tonic + axum bootstrap (gRPC + HTTP gateway)
├── http.rs HTTP/JSON gateway: status mapping, auth middleware,
│ includes build-time-generated routes
├── proto/
│ └── mod.rs tonic::include_proto! + pbjson serde
├── storage/ pluggable storage abstraction
│ ├── traits.rs Storage / StorageBackend / IndexableStorage
│ ├── filesystem.rs local FS backend
│ ├── s3.rs S3 / R2 backend (path-style, force-checksum-suppression)
│ ├── encrypting.rs wraps any backend with envelope-encrypted writes
│ ├── crypto.rs ChaCha20-Poly1305 + KEK envelope
│ └── ...
└── wallet/
├── advanced/ wallet-creation pipeline
│ ├── spec.rs SpendingCondition / WalletSpec / validation
│ ├── shape.rs WalletShape + classify()
│ ├── descriptor.rs typed descriptor builders per shape
│ ├── taproot.rs leaf-hash + internal-key extraction
│ └── build.rs build_wallet pipeline
├── bdk/ BdkWalletManager + KeyUtils + R2BackedStore
├── service/ RPC handlers (gRPC + HTTP both call here)
│ ├── mod.rs WalletRPC dispatch
│ ├── auth.rs bearer-token interceptor + HTTP middleware helpers
│ ├── system.rs Ping
│ ├── key_management.rs Create / Get / List managed keys
│ ├── wallet_creation.rs CreateGenericWallet — proto → WalletSpec → build_wallet
│ ├── wallet_operations.rs GetWallet / Update / RevealNextAddress / ListAddresses / GetSpendingPaths
│ └── transaction_handling.rs GetTx / GetUtxos / Fund / Sign / Verify / Finalize / Broadcast
└── signer/
└── mod.rs PSBT signing + leaf-hash → policy-path resolution

The clean entry point is wallet::advanced::build_wallet(spec, network) -> BuiltWallet. It runs the following stages:

  1. Spec. SpendingCondition[] from the proto request is parsed into a WalletSpec. Validation fails early on inconsistent inputs (e.g. a MULTI policy with threshold > managed_key_ids.len()).
  2. Classify. shape::classify() reduces the spec to a WalletShape enum: SingleSig, Multisig { threshold, keys }, Taproot { primary, recoveries }, etc.
  3. Descriptor. Per-shape descriptor builders emit a multipath BIP-388 descriptor. Taproot shapes go through Liana for the primary + time-locked recovery compilation.
  4. BDK. The descriptor is loaded into a fresh bdk_wallet::Wallet, persisted to the configured storage backend.
  5. Taproot metadata. For taproot wallets, taproot::extract_leaf_info() walks the script tree and returns one TaprootLeafInfo per leaf so clients can later select a spending path by leaf hash.

wallet::signer::sign_psbt_with_taproot_support is the thing that knows how to map a leaf hash back to a BDK policy-path index. The signer:

  1. Loads stored signers for the wallet’s managed keys.
  2. Resolves the selected_leaf_hash to a policy_path (vec![0] = primary / keypath, vec![idx + 1] = the Nth recovery leaf).
  3. Calls Wallet::sign(...) with the constructed SignOptions.

Pre-signed PSBTs from external signers (hardware wallets, air-gapped signers) skip the in-process signer entirely and go through add_verify_transaction_signature, which combines partial signatures into the stored PSBT.

Three persistence concerns:

  1. BDK file_store. Per-wallet binary blob (wallets/<wallet_id>/bdk.store) holding chain state, transactions, address index. Read on load_wallet, written on every wallet.persist(). R2BackedStore proxies a local bdk_file_store::Store to the configured AnyBackend (local FS or S3) on every persist.
  2. walletrs models. Indexable JSON documents under their own scopes — StoredWallet, StoredManagedKey, StoredPSBT, StoredSignedPSBT. The EncryptingBackend wrapper transparently envelope-encrypts payloads with WALLETRS_KEK before writes and decrypts on reads.
  3. Per-wallet locks. WALLET_LOCKS is a HashMap<wallet_id, Arc<Mutex<()>>> — every load / create acquires the lock for the duration of the call. Locks are surface-agnostic; gRPC and HTTP requests contend on the same map.

The HTTP surface reuses the gRPC WalletService impl with no per-RPC glue:

  1. proto/walletrpc.proto annotates each RPC with option (google.api.http) = { post: "/wallet/<snake>" body: "*" }; — the single source of truth for the path mapping.
  2. crates/server/build.rs compiles the proto with tonic-build, emits a FileDescriptorSet, then runs two extra steps:
    • pbjson-build adds serde::Serialize / Deserialize impls to the prost types so JSON encoding follows proto3 JSON semantics.
    • A custom step reads the descriptor with prost-reflect, walks every RPC’s (google.api.http) extension, and writes http_routes.rs — an axum Router that decodes the JSON body into the prost message, wraps it in a tonic::Request, dispatches through WalletService::<rpc>, and serializes the response.
  3. crates/server/src/http.rs include!s the generated routes, maps tonic::Status to HTTP status codes, and provides a thin axum middleware mirroring the gRPC AuthLayer for the bearer token.
  4. main.rs runs both servers under one tokio::select!. They share WALLETRS_HOST, separated by WALLETRS_PORT (gRPC) and WALLETRS_HTTP_PORT (HTTP).

Adding a new RPC is one annotation in the proto plus the usual handler addition. The HTTP route generates itself.

  • Multi-tenancy. The wire contract carries user_id on every request because the same proto serves single-tenant OSS deployments and multi-tenant operators that run a walletrs instance per logical tenant. The OSS distribution does not enforce tenant isolation — operators that need it run multiple instances or wrap walletrs behind a tenant-aware proxy.
  • Hardware wallet integration. Signing with HWWs is the client’s job. walletrs accepts pre-signed PSBTs via AddVerifyTransactionSignature and combines them in-place; it does not talk USB / HID.
  • Built-in TLS. Run a reverse proxy (Caddy / Traefik / nginx) for TLS termination on either surface.
  • Audit log. No append-only history of mutations today.