Breez SDK Spark and Radar Chat: Recovery Phrase Is Not the Same as Self-Custody
Tracing a Bitcoin wallet's exit path through its actual source code, not its marketing copy
Breez SDK Spark and Radar Chat: Recovery Phrase Is Not the Same as Self-Custody
A wallet giving you a 12-word backup phrase and a wallet giving you actual control over your Bitcoin sound like the same thing. They aren’t, and the gap between them is exactly what this post is about.
The trigger was a public dispute, not a hunch. A Bitcoin developer, @notgrubles, posted that no wallet built on Spark — a newer Bitcoin layer-2 system — actually implements “unilateral exit” in practice, meaning users fully trust a third party regardless of what the underlying protocol claims to support. Francis Pouliot, CEO of Bull Bitcoin, agreed: “a Spark wallet is pretty much a custodial wallet unless you can actually exit unilaterally if the servers go down.” WalletScrutiny picked one Spark-based wallet, Radar (“Radar: Chat & Bitcoin,” a Signal fork adding Bitcoin payments), and went and checked. Key technical claims below are backed by exact source-code citations, not a press release — a few broader statements (repo-wide searches that turned up nothing, issue tracker checks) describe the search performed rather than a single line, since there’s no specific line a “no result” points to.
Part 1 — Two very different ways to “get your money out”
Spark works by having your own keys plus a separate Spark service/operator
infrastructure jointly authorize your everyday transfers. It’s not even a simple “one
company cooperates with you” setup: Breez SDK Spark’s default configuration pools
three independent operators — Lightspark, Breez’s own, and Flashnet —
confirmed directly in the SDK’s default config,
and requires 2 of those 3 to agree
(split_secret_threshold: 2),
on top of your own key. Whatever the exact internal shape, the practical fact holds:
your key alone isn’t enough for a normal transaction; outside infrastructure has to
cooperate too. That single fact splits “getting your bitcoin onto a normal on-chain
address” into two completely different operations, and mixing them up is the easiest
way to overstate what a wallet actually does:
- Cooperative withdrawal: you ask to withdraw, and the service provider helps build and broadcast the transaction. This is the “normal” path — but it only works as long as that service is running and willing to help you.
- Unilateral exit: you move your funds to a normal Bitcoin address entirely on your own, using pre-signed exit transactions, with no cooperation needed from the service at all — even if it disappeared or refused to help. This is the one that would actually make a wallet self-custodial in the strict sense: exclusive user control, no third party’s permission required.
A wallet can support neither of these, one, or both. Marketing copy tends to blur that distinction; source code doesn’t.
Part 2 — Where to look, and what to look for
Radar ships on two platforms with two separate, public repositories:
radar-labs/radar-android (Kotlin) and
radar-labs/Radar (Swift, the iOS app that’s
actually live on the App Store right now). Both depend on the same underlying payment
engine, Breez SDK Spark, pinned at the exact same
version (0.14.0) on both platforms.
The investigation had two layers, and both had to be checked, because a wallet can fail either one independently:
- Does Radar’s own app code ever call a withdrawal or exit function? This means
cloning both repos and grepping every source file — not just the payments code — for
the vocabulary of moving funds out:
withdraw,exit,unilateral,leaf(Spark’s term for an individual UTXO-like unit inside the wallet),refund,recover. Also worth checking: every string shown in the app’s UI, and the GitHub issue tracker, in case an exit feature is planned but unshipped. - Does the SDK even expose that capability to the app in the first place? Android
and iOS apps talk to Breez SDK Spark through auto-generated bindings (via a tool
called UniFFI), which take a Rust function and
mechanically produce a matching Kotlin or Swift method with the same name. If a Rust
function in the SDK isn’t explicitly marked for export, there is no way for the app
to call it — full stop, regardless of what the app’s developers want. So the second
check is: clone
breez/spark-sdkat the exact tag both apps pin (0.14.0), and read which functions are actually marked exported.
Part 3 — What the code actually shows
Cooperative withdrawal exists in the SDK — Radar’s own code just never reaches it.
The SDK does support sending Bitcoin to a normal on-chain address: its prepare_send_payment
function has a dedicated
InputType::BitcoinAddress
branch, and send_payment follows through by calling
spark_wallet.withdraw(...)
(defined here),
which calls coop_exit_service.coop_exit(...) —
a real, working cooperative withdrawal, provided the Spark service cooperates. Radar’s
own outgoing-payment code never touches this, on either platform:
- On iOS, the app’s internal
PreparedTransactiontype (PaymentsImpl.swift#L1660) has exactly two cases:.lnurlPayand.bolt11. There is no Bitcoin-address case at all. A stray one reaching that code path is treated as a bug (owsFailDebug("Unexpected payment method for BOLT11 invoice.")), not a valid outcome. - On Android,
sendPayment()inBreezSdkWrapper.ktonly calls the pair of functions meant for paying Lightning addresses, and its own internal helper throws an error the moment it’s handed a Bitcoin address instead — before the request would ever reach the SDK’s withdrawal function.
Unilateral exit isn’t reachable at all — and this time it’s not Radar’s choice. The
underlying Spark protocol genuinely has a
unilateral_exit()
function. But it lives inside an internal Rust crate that the SDK’s own breez-sdk-spark
layer never re-exports through UniFFI at version 0.14.0. Reading every function actually
marked for export (there are about 30, covering payments, contacts, deposits, lightning
addresses, and wallet sync) confirms unilateral_exit isn’t among them. Even a
hypothetical, more ambitious version of Radar couldn’t wire up a “recover my funds
independently” button today — the tool to build it with doesn’t exist yet in the SDK
version both its apps depend on.
Part 4 — The recovery phrase: real, but not what it looks like
Radar does have a genuine, working 12-word backup phrase system on Android — and this is exactly the part worth being precise about, because it’s the easiest thing to mistake for “self-custodial.”
On Android, wallet creation generates 16 bytes of device randomness
(Entropy.generateNew())
and turns it into a standard 12-word BIP-39
phrase, checking its own math at generation time — encode to words, decode back, assert
the bytes match
(Entropy.asMnemonic()).
Typing the words back in reverses that exact operation. Clean, standard, textbook BIP-39.
iOS creation follows the same entropy-to-mnemonic path, both for the phrase actually
shown to the user
(PaymentsImpl.passphrase(forPaymentsEntropy:))
and for initializing the SDK
(BreezSdkExt.build)
— though that second function’s own comment is stale, still describing “32 bytes” and a
“24-word mnemonic” when the current constant is 16 bytes / 12 words. Creation itself
isn’t affected; it’s just a leftover comment worth flagging.
Its restore path is a different matter — it calls the wrong function, and unlike the
question of whether the SDK exports a unilateral-exit call, this one is fully checkable
from source. When you re-enter a phrase, iOS doesn’t decode it back into
the original random bytes the way Android does — it runs the words through
MnemonicSwift.Mnemonic.deterministicSeedBytes(...),
which traces back to a
PBKDF2SHA512 call
inside the underlying zcash/swift-bip39 library,
implemented here
using Apple’s CommonCrypto PBKDF2 with the SHA-512 PRF. That’s a real, standard BIP-39
operation — the seed-derivation function used for deriving keys — but not the
entropy-recovery function Android uses, and it hands back the wrong size: BIP-39 seed
derivation always produces a 512-bit, 64-byte value, never the 16 or 32 bytes Radar’s own
entropy field accepts
(PaymentsConstants.supportedPaymentsEntropyLengths).
That length check lives in
PaymentsState.build,
which silently falls back to .disabled instead of storing the oversized value — so the
Breez SDK is never actually initialized with it. But the restore flow’s own success check
doesn’t notice: enablePayments(withPaymentsEntropy:) returns true
regardless of whether the length check passed,
so the
restore-complete screen tells the user it worked
while payments quietly stay disabled. That’s a static-source finding, not a guess — the
one thing an actual device test would still add is confirming exactly what the user sees
afterward (presumably a silently-disabled Payments tab), not whether the restore itself
succeeds. It doesn’t.
Here’s the distinction that matters regardless of that iOS detail: even in the best case, where the phrase restores the wallet perfectly, that only gets you back into your same wallet, using Radar itself or another app that speaks the Spark protocol the same way. It proves the funds are yours in the sense of restoring access — it is not, by itself, a way to move bitcoin to a normal on-chain address if the Spark service Radar depends on ever goes offline or refuses to cooperate. Restoring a wallet with those words still needs software that understands Spark, and that software still needs to talk to a Spark service to figure out the current state of your funds and build a valid withdrawal — the same cooperative arrangement from Part 1, which stops working the moment the cooperating service is gone. That’s what unilateral exit would solve, and as shown above, it isn’t there.
One more thing worth correcting up front, because it was the most tempting assumption to
get wrong: it’s not Radar’s own servers standing in as that cooperating party. Neither
app overrides the SDK’s default configuration, which points at Lightspark’s own
infrastructure (api.lightspark.com,
confirmed in the SDK’s default config).
The dependency on outside cooperation is real, it’s just not Radar-the-company you’d be
depending on.
Takeaways
- A working recovery phrase and independent fund recovery are two different claims. The first restores access to the same wallet through the same (or compatible) software; the second lets you get bitcoin out on a normal address with nobody’s permission. A wallet can have the first without the second, and that’s exactly what “self-custodial” marketing tends to gloss over.
- “The protocol supports X” and “the app lets you do X” are different sentences. Spark the protocol does support unilateral exit. Whether a specific wallet app can actually trigger it depends on whether the SDK’s binding layer exports that function at all, and separately, on whether the app’s own code ever calls it. Both checks are necessary; neither one alone is enough.
- Cross-check both platforms when a wallet ships on more than one. Radar’s Android and iOS apps reach the same conclusion through genuinely different code paths — and the recovery-phrase restore mechanism turned out not to be identical between them at all, which wouldn’t have surfaced from reading just one platform’s source.
- Don’t assume the “cooperating service” is the company whose name is on the app. Radar’s apps don’t configure their own Spark infrastructure — they use the SDK’s default, which pools three completely different companies (Lightspark, Breez, Flashnet). Read the actual configuration instead of assuming.
- Not every question needs a device test — but check before assuming one does. This post originally filed iOS’s restore path as unconfirmed pending hands-on testing; tracing the actual entropy-length check and the restore screen’s success path showed the mechanism was fully verifiable from source. iOS’s phrase restore silently fails, and the app tells the user it succeeded anyway.
Full technical writeup, with every citation, lives on WalletScrutiny’s Radar entry.
