Bitkey: The Bitcoin Hardware Wallet for Your Normie Friends?
TLDR: Block published the Bitkey source. I read it. The 2-of-3 is real, privacy is decent, coin control is built but wired into nothing
Is Bitkey the Hardware Wallet for Your Normie Friends?
You’ve been the family’s bitcoin help desk since 2017. Readme before you put your name on another recommendation.
Someone at the family barbecue asks you how to secure their bitcoin and now your trapped in a false dichotomy. Tell them to just leave them on the exchange and you’re the idiot who said hodl on an exchange. Tell them twelve words on a card and you’re the idiot who gets the 2am call when the card is missing 4 years from now when they want to panic sell.
Bitkey is offering a third option. Three keys no seed phrase and a “trusted” third party to hold one of the keys. Yeah the latter should raise an eyebrow. Bitkey published the code under MIT license; so you instead of trusting their marketing page we can take a look under the hood.
So I took a look. Three things I recommend considering before YOU recommend it to anyone who shares your last name:
-
Block can’t touch your funds without you. This is harcoded, it doesn’t live in a corporate policy document but in one line of Rust.
-
Coin control (UTXO Mgmt) is fully built but wired into nothing. Every send uses
Default. Someone outside Block opened a PR to finish it. I’m personally watching what happens to that PR. -
Older wallets dox the balance to Block; newer ones don’t. Check for Private Wallet Update in Settings and run it if it’s there: on new devices it’s on by default.
Disclaimer: I read source. I did not run a restore, blackhole a backend, I did operate the app as a customer. So as always DYOR and don’t blindly trust me on it.
Three Keys No Seed Phrases
Normal hardware wallet: one key, don’t lose it. Bitkey: three keys, don’t lose two.
Every wallet is 2-of-3 multisig. Any two move funds. No single key can. Declared once in a line of Rust:
Descriptor::<DescriptorPublicKey>::new_wsh_sortedmulti(2, vec![self.app, self.hw, self.server])
Line of code lives in: server/src/api/bdk_utils/src/lib.rs, DescriptorKeyset::into_multisig_descriptor. Block holds key three. Permanently one signature short so they couldn’t check your balance if they wanted to.
server/src/api/bdk_utils/src/lib.rs:306.The three keys are in different places. That’s the whole idea:
- App key. Phone keystore, backed up to their cloud.
- Hardware key. On the Bitkey, behind a fingerprint.
- Server key. AWS Nitro Enclave. Never leaves. Host process
wsm-apican’t reach it directly. Own the API box and you still don’t get signing.
Any two work, so three pairs:
| Pair | What it unlocks | What has to be true |
|---|---|---|
| App + hardware | Ordinary spending | A fingerprint match on the device. The server’s never consulted. |
| App + server | Mobile Pay, lost-hardware recovery | Every spend rule passes, or a notification delay elapses. |
| Hardware + server | Lost-phone recovery | A notification delay elapses without the owner canceling. |
Look at the first row again. Normal spends never talk to Block. That matters later.
Two routes to the second signature
The app always signs first. The question is where signature number two comes from.
Small spends: Mobile Pay. Server co-signs. Hardware stays in the drawer. Over the limit: dig the Bitkey out and put a finger on it.
Mobile Pay is not a rubber stamp. server/src/api/mobile_pay/src/spend_rules/ has six rule concepts. Mobile Pay runs five. A recovery sweep runs three:
| Rule | Mobile Pay | Sweep |
|---|---|---|
AddressScreeningRule | yes | yes |
AllPsbtInputsBelongToWalletRule | yes | yes |
DailySpendingLimitRule | yes | no |
NoPsbtOutputsBelongToWalletRule | yes | no |
TransactionVerificationRule | yes | no |
AllPsbtOutputsBelongToWalletRule | no | yes |
Don’t read the directory listing as the rule list or you’ll get it wrong. The two output rules are inverses. NoPsbtOutputsBelongToWalletRule kills self-sends on Mobile Pay (is_addressed_to_self). AllPsbtOutputsBelongToWalletRule requires every output belong to the destination wallet on a sweep. Private keysets get _v2 variants, which is why six concepts turn into eleven impl Rule blocks. Address screening is the only one without a V2.
Also: signing_processor.rs is a typestate. Initialized → Validated → Signed. You cannot reach Signed without Validated. Type system. Not a comment somebody deletes on a Friday.
Two things from the table.
The easy path is the path where Block looks at where the money goes. The path where nobody looks costs a fingerprint. I understand why: A co-signer that signs whatever you hand it is worthless. Once you’ve built the inspection point the screening is right there. So the company can’t steal the coins but what does it check before it signs? And can they change the rules later?
DailySpendingLimitRule needs a counter. That counter is a DailySpendingRecord that rolls at 3 a.m. Mobile Pay history is stored on the server on purpose. Remember that.
What you’d hate about it
- No self-sends on Mobile Pay. Hardware path or nothing. Same
is_addressed_to_selfrule. - Only Block talks to the device. WCA over ISO7816 APDUs. Nobody else implements it.
bumpver.jsonmentionscli/Cargo.tomlandcli/is not in the public tree. - Mobile Pay history lives on the server. Ammounts and timestamps doxed (to Block).
- Electrum endpoint comes from a remote feature flag Unless you set your own.
One privacy note
Older legacy wallets dox your balance to Block: the server holds xpubs and can derive every address. Newer private wallets don’t. If Settings still shows Private Wallet Update, run it. If it doesn’t and the app is current, you’re probably already on the new version.
Private still leaks your mobile pay info to Block, kinda important to know before you set someone up.
Transaction notifications register receive addresses. New receive address gets sent to the backend and stored in a table mapping addresses to account IDs. One gate:
if (isMoneyMovementNotificationEnabled()) {
registerWatchAddressProcessor.process(...)
}
Turn on notifications and you turn on address disclosure. Same switch: Whole balance doxxed to Block.
I don’t think this is malicious or sneaky tho. it’s just math. A server can’t ping you about an address it can’t see and a private wallet exists so the server can’t see your addresses. Something has to give. What gives is you hand the addresses over on purpose. Fine tradeoff if you know you’re making it. Bad tradeoff for a toggle that just says “notifications,” which your sister will flip on without thinking. The hole is right on the label.
Mobile Pay discloses in both modes. To co-sign, the server gets the whole PSBT: inputs, outputs, amounts. Spend rules don’t work otherwise. Add the persisted DailySpendingRecord and… you get the picture by now or you stopped reading cause this is all too technical anyway.
Sync traffic goes to a third party Block picks remotely. Electrum endpoint comes from the backend. Value comes from a LaunchDarkly feature flag. Config names mempool.space and Blockstream on bitkey.* subdomains. Block doesn’t appear to run those nodes, but the subdomain means whoever does can pick Bitkey users out of traffic with zero effort. And because the endpoint is a remotely flippable flag where those address queries go is Block’s runtime call, it’s not a property of the binary your friend installed.
Account metadata either way. Account exists, keyset IDs, attested hardware serial, notification touchpoints, recovery and inheritance relationships.
So: they built a real blindness mechanism, kept a legacy path where it doesn’t apply, and put the switch that partially undoes it on a notifications screen. This all kinda makes sense when you look at who the product is marketted towards and what Mobile Pay still needs to run.
Coin control: built, not shipped
I’d heard people are kinda miffed about “no UTXO management” so I dug a little verify so I could move on. What’s in the tree is weirder.
Consolidation is shipped & Coin control is built but didn’t ship.
UtxoConsolidationService builds a consolidation PSBT the customer signs with hardware. Real feature with sharp edges:
- Only
ConsolidateAllis implemented.UtxoConsolidationTypealso declaresMaxValuePerUtxo, which would sweep only UTXOs under a threshold, but the service still returns a singleConsolidateAllparams object behind aTODO. All or nothing today. - Needs at least two confirmed UTXOs, per
NotEnoughUtxosToConsolidateError. - There’s a cap, and the reason for it is my favorite detail in the repo. From
UtxoConsolidationServiceImpl.kt:
// After a certain number of UTXOs, the signing process can timeout on iOS. We enforce a
// maximum number in a single consolidation.
val maxUtxos = utxoMaxConsolidationCountFeatureFlag.flagValue().value.value.toInt()
An iOS NFC timeout became a product limit on both platforms. Over the cap it picks the “N lowest-value UTXOs” which is fine and techinically correct if what you want swept is dust.
The coin control code is already there
This surprised me. SpendingWallet.kt:
sealed interface CoinSelectionStrategy {
data object Default : CoinSelectionStrategy // BDK chooses from all inputs
data class Strict(val inputs: Set<BdkTxIn>) // only these
data class Preselected(val inputs: Set<BdkTxIn>) // these, plus more if needed
}
BdkTxBuilder.addUtxos() and manuallySelectedOnly() are underneath. The machinery is done.
Every user facing send still passes CoinSelectionStrategy.Default. The only non default uses are Preselected(...) inside BitcoinWalletServiceImpl, and those aren’t user choice: they reuse one PSBT’s inputs across the 30- and 60-minute fee options so the estimates match. Every UTXO UI I found is consolidation, a tx detail view, or BitcoinWalletDebugScreen under statemachine/dev/.
So you can’t pick coins, freeze a UTXO, or label it. Not because the wallet can’t. Because there’s no UI built.
Somebody outside Block wrote the missing piece
August 1: PR #7, “Add ability to select UTXOs when sending sats” from a personal fork. Maps a CoinControl value onto CoinSelectionStrategy.Strict, threads it amount → fees → confirm, null keeps today’s automatic behavior. Three commits. Co-authored with an AI agent.
The notes make sense. Strict fails closed: underfunded selection errors instead of silently grabbing more coins. They also call out that the existing Preselected fee-ladder junk is not coin control. I almost made that mistake myself.
Open when I wrote this. No reviews. No assignee. No labels. Four days old, so calm down. But “left out on purpose” vs “just not shipped” now has a public place to die. Watch the PR. Ignore the roadmap posts.
The barbecue problem
Coin control exists so you don’t link coins that shouldn’t be linked. Consolidation does the opposite: it smashes UTXOs into one output and welds the histories together on a public chain forever.
Bitkey shipped consolidation. Not coin control.
You can defend that. Lower fees later. Works around the iOS timeout. Say the quiet part though: they optimized privacy from Block, not privacy from chain analysis. Different enemies. The product picked the former.
But the former and the latter collide. Private-wallet migration and the W3 upgrade both throw a “consolidation required” sheet. So the upgrade that stops Block deriving your addresses can first force you to publish a linkage graph of your coins to the entire network. One privacy model eating the other.
This is a product for people who will never learn what a UTXO is. For them consolidation is fine and coin control is a menu they’d never open. If you’re a purist you will hate this wallet. The question is whether your brother-in-law will notice, and whether you noticed before you told him to buy it.
Where it actually earns its keep
Because two keys suffice, losing any one leaves a working pair. Bitkey ships three recovery mechanisms in parallel.
Delay and Notify is the cheapest good idea in here. Proving a stranger is you is hard and ugly, so Bitkey changes the problem: give them the signature eventually but make the wait loud enough that the real owner can cancel. Guarantee is elapsed time plus a notification channel, not a credential. So it only works while notifications work and your friend is reachable. New number, dead email, two weeks off-grid: waiting period nobody’s watching.
Social recovery is the one that matters for you, because you are the feature. Recovery Contacts endorse. SPAKE2 turns a short code on a phone call into an authenticated channel. Authenticity via appGlobalAuthKeyHwSignature (app auth key signed by hardware). Contact only needs a LiteAccount: recovery auth key, no wallet. No Bitkey. No bitcoin. No interest in bitcoin required.
Flip it: you can be your dad’s Recovery Contact. He doesn’t understand any of this. You don’t hold his keys. Nobody babysits a scrap of paper. Better than whatever you’re doing now, which is probably a text with a photo of a seed phrase you’ve meant to delete since 2021.
Inheritance runs create → lock → complete, plus cancel and shorten_delay. Every transition visible to the person whose coins are moving, while they’re still around.
None of this weakens the 2-of-3. Different ways to establish the asker is the owner, with enough delay and noise that a bad attempt gets caught.
What if Block disappears?
You’ll get asked. Most coverage treats it as binary. Wrong. Three tiers. Depends what else vanished with the company.
| What’s gone | What you need | Works on iOS? |
|---|---|---|
| Block only. App installed, device in hand. | Nothing special. Just spend. | Yes |
| Block and the phone or app install. | Emergency Exit Kit restore into a fresh install. | No, Android only |
| Block and the Bitkey device. | Nothing helps. Two of three keys gone. | No, neither |
Tier one: the app already handles it
App you’ve got keeps working when the backend dies. Sending stays on. LimitedFunctionalityCause.kt has a degradation table. For F8eUnreachable:
send = Available,
receive = Available,
customElectrumServer = Available,
utxoConsolidation = Available,
mobilePay = Unavailable,
securityAndRecovery = Unavailable,
inheritance = Unavailable,
fiatExchangeRates = OutOfDate(lastUpdated = lastReachableTime),
Lines up with everything above. Local app key + device over NFC. Broadcast through Electrum with a third-party fallback. None of that path needs Block. Mobile Pay goes dark because it actually needs the server.
EmergencyExitMode and InactiveApp are separate causes and both keep send = Available too. Somebody modeled more than one flavor of “server’s gone.”
Works on both platforms. This is what most people mean when they ask the question.
Tier two: Emergency Exit Kit
For when the app install is gone, not just the company.
PDF in their cloud. Encrypted payload plus printed instructions. Payload has the app spending private key and both other public keys, with paths, origins, fingerprints: enough to rebuild the descriptor.
There’s a break-glass Android flavor for restore. Restored account pinned to ForceOffline → https://offline.invalid. Network requests hard-fail. README is blunt:
enough data to move funds, not enough data to operate the full online app experience
app/verifiable-build/android/ lets you rebuild and diff against the installed APK. You don’t have to trust a binary someone emailed you.
Catch: payload sealed with a hardware-held key. Unsealing needs the physical Bitkey. That’s why tier three exists.
Tier three: nothing helps
Lose Block and lose the device and two of three keys are gone. Lost-hardware recovery is normally app + server. Server’s missing.
That’s 2-of-3. Not a bug. No patch. Marketing will never lead with this. Say it out loud to whoever you’re setting up.
Weak link in tier two is a URL
The PDF tells you where to download the emergency app. QR code. SHA-256. That URL is not in source. Injected at release by a build task (apkUrl, apkHash, apkVersion) and baked into that PDF.
So if Block’s gone, the PDF may have perfect instructions for a file that no longer exists.
The printed hash is how you know someone thought about it. A hash only matters if you get the file somewhere that isn’t the official link. That’s also the fix: item four below.
Don’t panic-buy this problem. It only hits tier two: lost the phone and the company. Working install, they never need the APK.
Why you can’t just rescue them with Sparrow
You already thought of this. Kill it.
Emergency Exit Kit gives you a standard wsh(sortedmulti(2,...)) descriptor and the app private key. Import watch-only into Sparrow or Electrum: one signature. You need two.
Second signature is hardware. Hardware speaks WCA. Nothing outside Block implements WCA. Block’s Android app is currently the only shipped software that talks to a Bitkey. Sold as self-custody; keys are theirs-to-hold and Block’s-to-door. Fairest hard criticism in the pile.
Opening exists: app/rust/wca is published. Someone could build an independent signer. That is not a weekend panic project. That is a real project. Useful. Not what you do during an emergency.
Four things when you set it up
You’ll do the setup. Do these while the phone is in your hand:
- Check for Settings → Private Wallet Update (gear, not shield). If it’s there, run it so you’re not on a legacy wallet that doxes the balance to Block. If it’s gone and the app is current, you’re probably already on the new kind.
- Decide about transaction notifications on purpose. Same switch as address disclosure. Alerts are fine. Know what got traded.
- Set a custom Electrum server. Pulls the endpoint off a remotely flippable flag. Stays available when the backend dies.
- Download the emergency APK now while the link works. Stick it next to the PDF in their cloud. Keep the printed hash. Ten minutes. Company URL becomes a local file.
Then put yourself down as Recovery Contact.
What I didn’t verify, and what I got wrong
Structure from source. Nothing executed:
- Never ran a restore. No Exit Kit generated, decrypted, or used to move coins.
- Degradation table is a declaration. Code says
send = Availablewhen backend’s unreachable. I did not blackhole the network and watch a send succeed. - Can’t read live feature flags. Config names Electrum providers; the endpoint served is runtime. Check the app, not a URL from this post.
- Coin control: read code, didn’t drive the app.
Defaultat every send site is strong. Can’t rule out a surface I missed. - Consolidation cap is a live flag. A test uses a number; I’m not printing it as gospel.
MaxValuePerUtxomay have shipped since this tree. TODO only proves it hadn’t then.- Published subset.
docs/,cli/, some workflows missing. Absent from public tree ≠ nonexistent.
If you audit this yourself, read this part.
I first treated the Exit Kit as the front line and said iOS users were cooked if Block vanished. Both wrong. Found the Android break-glass flavor, filed it, stopped looking. Never checked whether the normal app already handled an unreachable backend. It does. Every platform.
Got corrected because someone said “but my iOS app works fine.” Code reading loses to one person with the thing installed.
Real iOS finding: they can still spend if Block dies, with the app they already have. What they don’t have is tier two. Lose or replace the iPhone after the company is gone and Exit Kit restore needs Android. “iOS users are exposed” gets you dunked. “iOS has no recovery path if you lose the phone after the company is gone” is right.
Finding a mechanism is not finding the mechanism. Some of my mistakes came from stale docs in the repo. Audit the code. Distrust the prose around it.
So, do you recommend it?
Not to yourself. You’d last a week. Missing coin control and the single-signer lock-in would send you back to something you control end to end, and you’d be right. Check back if PR #7 lands.
To the person who’d otherwise leave it on an exchange, or write twelve words on a notepad and lose the notepad: yes. Do the four chores. Put your name in Recovery Contact. Seed phrase is gone. Company is one signature short by construction; one line of Rust, not a blog post (including this one). Losing any one of the three things is survivable. That’s more than the setup they’d have otherwise.
They’re also buying a tradeoff list. I can hand it to you because Block published the code. Mobile Pay history hits the server. Only Block’s app talks to the device today. Lose device and company together and it’s over. Same as any 2-of-3.
Don’t invent a conspiracy. They built this for the brother-in-law who will never open Sparrow, and made the choices for that user base. Don’t stop at “Block is based” or “they hold a key, burn it.” Read the script. Decide whose problem you’re solving.
A tradeoff list you can actually read is the highest compliment I’ve got for a custody product. Most alternatives hide a longer one. Set it up. Do the chores. Put yourself in Recovery Contact and stop worry about your grandmas seed phrase.