TECHNICAL REPORT · FSA-2026-002 · 27 August 2026
Change addresses beyond the recovery range
Component: Frostsnap app, send screen
Present in the codebase: 29 January 2025 to 16 August 2026
Affected releases: every release from v0.0.0-alpha to v0.3.0; worst from v0.2.0
Status: fixed in v0.4.0, which also scans the keychain deeper and consolidates stranded coins
Prepared: 27 August 2026, from the project’s commits, pull requests and code. Statements about intent are inferences and are labelled as such.
Contents
- Summary
- Timeline
- 1 · The defect
- 2 · Impact
- 3 · Root cause
- 4 · Related defects
- 5 · The fixes
- 6 · Review by current AI models
- 7 · Recommendations
- Sources
Summary
BuildTxState::fee() was a #[frb(sync)] getter on the send screen’s render path, called on every redraw. To get a fee to display it called CoordSuperWallet::send_to, the transaction builder. Whenever coin selection produced change, send_to revealed the next internal keychain index, persisted the reveal to sqlite, and marked the index used. Every redraw of the fee row therefore consumed a change address.
On chain, the result is change outputs at high derivation indices. A gap-limited recovery stops before it reaches them, so the coins are invisible to, and unspendable by, any wallet that did not allocate the address itself.
The fix makes the planning path pure: the commit step is now the only place an address is allocated.
This report is for engineers. The bulletin covers the same issue for users.
Timeline
The mutation inside send_to was correct when written. What changed was who called it.
| Date | Event |
|---|---|
| 19 June 2024 | send_to gains change allocation. Correct: the only caller makes a real payment. |
| 29 January 2025 | #202 builds a new fee-rate picker that calls send_to to display a fee, once per interaction. Looking at a fee estimate now allocates an address. |
| 26 November 2025 | #381 reworks the send flow. The call becomes a synchronous getter evaluated on every redraw: per frame instead of per interaction. |
| 15 August 2026 | A user reports funds missing from a watch-only view of their wallet. |
| 16 August 2026 | #542 separates planning from committing; #543 persists what synchronisation learns and widens the derivation margin. |
| 17 August 2026 | #544 derives change reservations from live signing sessions; #545 sweeps stranded coins into outgoing payments. |
| 20 August 2026 | #550 detects stranded coins and offers a consolidating self-spend. |
| 25 August 2026 | v0.4.0 released with all of the above. |
| 27 August 2026 | This report published. |
1 · The defect
The send screen displayed the fee by calling fee(), which called send_to and read the fee from the result:
#[frb(sync, type_64bit_int)]
pub fn fee(&self) -> Option<u64> {
let inner = self.inner.read().unwrap();
let mut sw = self.super_wallet.inner.lock().unwrap();
sw.send_to(
self.frost_key.master_appkey(),
inner.recipients.iter().filter_map(|r| { /* … */ }),
inner.feerate()?,
)
.ok()?
.fee()
}
#[frb(sync)] makes the function callable from Dart synchronously, with no await.
Inside send_to, whenever coin selection produced change, the wallet allocated a change address:
if let Some(value) = cs.drain_value(target, change_policy) {
let mut db = self.db.lock().unwrap();
let (i, _change_spk) = self.tx_graph.mutate(&mut *db, |tx_graph| {
Ok(tx_graph
.index
.next_unused_spk((master_appkey, BitcoinAccountKeychain::internal()))
.expect("…"))
})?;
self.tx_graph
.MUTATE_NO_PERSIST()
.index
.mark_used((master_appkey, BitcoinAccountKeychain::internal()), i);
// … push the change output on the template
}
Three things about this block matter.
- It writes to disk immediately.
next_unused_spkreveals the next unused change address andPersisted::mutatecommits the reveal to the database beforesend_toreturns. - Each call takes a new address.
mark_usedmarks the address just allocated, in memory, so the next call takes the one after it. - It only runs when there is change. A send-max payment produces no change and allocates nothing, and
fee()does nothing until a fee rate is chosen. An ordinary payment of part of a balance does produce change, so in normal use the allocation ran every time.
The UI called fee() once per redraw. The send page rebuilt on any change notification:
sub = state.subscribe();
sub.start().listen((_) => mounted ? setState(() {}) : null);
and the fee row read the value during the rebuild:
title: SatoshiText(
value: state.fee(),
…
So while the fee row was on screen, every redraw ran a full coin selection (about 360 microseconds), wrote to the database, and consumed one change address. Changing the fee rate, selecting or deselecting a signing device, refreshing fee estimates, editing the amount, and ordinary framework rebuilds all trigger a redraw. An automated test that drove the flow with no pauses saw the first payment’s change land on the third change address. A person choosing a fee rate and picking devices would consume more.
2 · Impact
Within one app session, each fee calculation that produced change consumed a fresh address, and the record of the highest address allocated only moved forward.
Across restarts the effect is smaller. The reveal (“this address exists; derive at least this far”) was persisted. The used mark (“this address is taken; hand out the next one”) was held only in memory. After a restart, every allocated-but-unused address looked free again and the wallet resumed from the lowest of them. Each session re-consumed the same low addresses before reaching new ground, and the change of a session’s real payment landed at roughly that session’s redraw count.
Exposure. Every payment with change made from the app in the affected window. Only a send of the full balance avoided it. Only one user is confirmed affected at the time of writing: coins were seemingly missing from a watch-only wallet built from their exported descriptor.
The app’s view. A wallet rediscovering its addresses uses a gap limit: derive addresses in order, scan them, and stop after a long enough run with no history. The app never needs to do that. Its database records the highest address it allocated, and on every start it re-derives up to that record plus a margin. Its own change stays within reach.
A recovered wallet’s view. A wallet rebuilt from a backup has no record of what was allocated: all it can do is scan addresses for on-chain transactions, and the gap limit bounds how far that scan goes. Change placed after a session of fee redraws sits past a run of unused addresses longer than the scan tolerates, so the scan stops before discovering it. The same applies to any view built from the descriptor alone: a restore on another phone, or a watch-only wallet in external software.
A wallet’s core promise is that backups plus the blockchain are enough to recover the funds. This defect broke that promise while leaving the wallet in daily use looking correct.
The reported case. The user’s app displayed the full balance, but its Send Max offered the same reduced amount as their watch-only wallet in Sparrow. The defects in section 4 can produce that state in a rebuilt database. We do not have the user’s database and do not claim their exact path into it.
Forensic indicator. An original, never-restored wallet database holds the highest address ever allocated:
SELECT descriptor_id, last_revealed FROM bdk_descriptor_last_revealed;
A large gap between that and the highest change address that appears on chain shows allocations were wasted, though it lumps fee redraws together with cancelled payments. A small gap proves nothing: restarts recycled the same low addresses, and a real payment can land at the recorded maximum.
The user’s report
Until the user’s report arrived, we did not know the app was wasting change addresses. The report showed the consequence directly: real coins sitting past the gap limit. During the fix work we reproduced the cause on a test network, where a scripted send placed its change on the third change address instead of the first.
The user loaded their exported descriptor into Sparrow as a watch-only wallet and saw only part of the expected balance. The Frostsnap app showed the full balance but its Send Max offered the same reduced figure as Sparrow. Raising Sparrow’s gap limit brought the missing coins into view, and address lookups confirmed the change belonged to the wallet.
3 · Root cause
Location. The call lives in the Rust code that publishes an interface to Flutter. That layer is read as plumbing: type conversions, getters, forwarded calls. Wallet behaviour is expected in the wallet crate and interface behaviour in Dart, so a reader’s guard is down in the bridge, which is where this call sat.
Intent. Its description is three lines: move the send flow’s modelling into Rust, simplify the fee-rate picker, simplify send-max. It replaced about three hundred lines of interface-side state handling with one state object read through synchronous getters.
fee() was written to be truthful. It mirrors the function that builds the transaction the user signs. Inference, high confidence: the intent was that the displayed fee could not differ from the real one, because both come from the same code. Pursuing that property is what put a transaction-building call on the display path.
Missing API. There was no way to ask “what would this payment cost?” without building the payment. One function returned the fee of an existing transaction, another the spendable balance, a third network fee-rate estimates. A pure estimate required separating coin selection from change allocation inside the wallet, which is the refactor the fix eventually performed. The right move in January 2025 was that refactor. Calling the transaction builder instead was the mistake.
Hidden mutation. The call site shows a lock and a method call. Rust’s signal for mutation, a &mut receiver, was absorbed by the mutex and carried no information anyway: the wallet’s read-only queries (list addresses, list transactions, compute balance) also took &mut. The allocation sits about a hundred and fifty lines into another function, in another crate, written fifteen months earlier for a caller where it was correct.
Lazy initialisation. The wallet library expects its address index to be built once, at construction. This codebase built it lazily: the first call to touch a key initialised its index, whichever call that was. Any method might be first, so every method took &mut self. The initialisation code carries a warning about the ordering hazard this creates. Once a display getter may call a wallet method that initialises the wallet, a getter that allocates an address is a short step away.
Review. Both pull requests were reviewed. The second review covers offline behaviour, a fee-rate calculation error found and fixed, interface problems demonstrated with a screen recording, and platform-specific defects, all found by running the app. The first was approved with praise for its interface work. No recorded comment on either examines what fee() calls.
Much of the review was empirical: run the app, find what misbehaves. This defect produced nothing observable. The fee shown was correct, the payment worked, and the allocated addresses appeared on no screen. Its only evidence was drift in a database column that nothing displays, which testing cannot reach and reading the call graph can.
The change that needed a call-graph read was the November 2025 rework: it moved a large amount of logic across the boundary between the interface and the wallet. No recorded review asked which of the interface’s calls write to the wallet.
4 · Related defects
Three pieces of code wrote to the address-tracking state, with no single owner of the rule that what a wallet re-derives after a restart should match what it used.
In-memory usage marks (deliberate, forced by the library). “This address is used” marks were never written to disk. The library’s persistence format has no field for them, and the method that sets them is named MUTATE_NO_PERSIST to say so. The incompleteness was not deliberate: addresses were marked used and never unmarked, so a cancelled payment kept its address reserved until the next restart. #544 deleted the mechanism and derives reservations from live signing sessions instead.
Discarded synchronisation record (not deliberate). When the wallet synced with the network it learned how far its addresses had really been used, computed a record of it, and dropped the record instead of saving it:
let indexer_changeset = tx_graph
.index
.reveal_to_target_multi(&update.last_active_indices);
let tx_changeset = tx_graph.apply_update(update.tx_update);
let changed = !(chain_changeset.is_empty()
&& indexer_changeset.is_empty()
&& tx_changeset.is_empty());
Ok((changed, (tx_changeset, chain_changeset))) // indexer_changeset not returned
Inference, high confidence: this was an omission. The record was computed and consulted, so it mattered. The storage layer had a table for it and already saved the same kind of record when a payment allocated an address, so persisting it was one line. No comment or review mentions dropping it. And where this codebase declined to persist on purpose, it said so through MUTATE_NO_PERSIST; here nothing says anything. When found in 2026 it was treated as a bug, and the fix added the record to the value already returned.
This defect can leave a balance visible but unspendable. The saved record never advanced, so after every restart the wallet re-derived too narrow a range. A coin beyond it was counted by the balance, which re-derives ownership as it goes, but never entered the set that coin selection reads, which is written only when a transaction is indexed. #543 fixed it and widened the derivation margin from twenty-five to fifty.
A smaller instance in start-up. The start-up code re-examines stored transactions and discards what it learns instead of saving it. The result stays in memory, so the coins it recognises are spendable for the session; only the saving is lost, and the repair is redone at the next start. On a database damaged before the fixes this repeats every start until a sync saves the corrected position; on data written since, it changes nothing. It remains in the code. It does no harm today, but nothing in its design prevents it from doing harm later.
5 · The fixes
#542 made the defect structurally impossible. A payment plan is now a value: plan_send computes one and allocates nothing, so the interface may call it as often as it likes; commit_send turns a plan into a signable transaction and is the only place in the wallet that allocates a change address; the fee on the review screen is a field of the plan. send_to was deleted so the two responsibilities cannot be recombined.
#543 saves what synchronisation teaches the wallet and widens the derivation margin, which also repairs damaged wallets at their next start. #544 replaced the in-memory reservation marks with a view derived from live signing sessions. #545 makes every outgoing payment sweep up coins already out of reach of a standard restore.
#550 handles users with stranded coins and no payment to make. The wallet finds stranded change by itself: it owns the coins an outgoing transaction spent, so it holds that transaction locally even when its change is unrecognised, and comparing the change against locally derived addresses is enough to recognise it. No server is asked. Coins a standard restore would miss are counted on the home screen, and the prompt offers a self-spend: affected coins in, one output back to a low-numbered change address. The tests pin the property that the prompt and the remedy use the same definition, so acting on the prompt always clears it.
6 · Review by current AI models
The defect passed two human reviews. To measure how findable it is, each change was checked out into a copy of the repository with all later history removed, and eight AI coding agents reviewed it: five commercial (Codex on GPT-5.6, Grok 4.6, Claude Opus 5, Claude Sonnet 5, Kimi K3) and three running locally on a laptop (Qwen3-Coder-Next, Qwen3.6-27B, Qwen3.6-35B). Two tiers of one family are included on purpose.
Each agent was shown the branch and its commits and asked one question: would you be happy for this work to continue, or does something need fixing first? The words fee, mutation, persistence, change address, side effect and security appear nowhere in the prompt. A review was classified identified if it named the fee display path as modifying and saving wallet state, adjacent if it flagged the per-redraw work without the state consequence, and missed otherwise.
Two changes were tested: the January 2025 change that introduced the pattern and the November 2025 change that magnified it. Each was shown as the whole change and as the introducing commit alone. The November change was also paired with a later, unrelated commit as a control.
January 2025. The calling code and the wallet code were modified in the same commit.
| Reviewer | The whole change | The introducing commit alone |
|---|---|---|
| Claude Opus 5 | missed | identified |
| Codex (GPT-5.6) | identified | missed |
| Grok 4.6 | identified | identified |
| Claude Sonnet 5 | missed | missed |
| Kimi K3 | adjacent | identified |
| Qwen3-Coder-Next (local) | missed | missed |
| Qwen3.6-27B (local) | missed | identified |
| Qwen3.6-35B (local) | adjacent | missed |
| Found it | 2 of 8 | 4 of 8 |
November 2025. The code that allocates the address was not part of the change; it sits in another component, called by a fifteen-line function that reads like a query. The introducing commit was shown twice to each commercial agent as a consistency check.
| Reviewer | The whole change | The introducing commit alone | A later, unrelated commit |
|---|---|---|---|
| Claude Opus 5 | identified | identified, identified | identified |
| Codex (GPT-5.6) | identified | missed, missed | no objection |
| Grok 4.6 | identified | missed, adjacent | identified |
| Claude Sonnet 5 | identified | missed, missed | no objection |
| Kimi K3 | adjacent | identified, adjacent | no objection |
| Qwen3-Coder-Next (local) | missed | missed | — |
| Qwen3.6-27B (local) | missed | missed | — |
| Qwen3.6-35B (local) | missed | missed | — |
| Found it | 4 of 8 | 3 of 13 | 2 of 5 |
Given the whole feature, current tools find it. Four of the five commercial agents identified it in the November change, each quoting the allocation code; the fifth reached the per-redraw work without its consequence. One recommended, unprompted, the design that shipped nine months later: separate coin selection from change allocation. None of the five existed when either change was written and reviewed, so the experiment measures what current tools find in earlier code, not what was findable then.
Shown only the introducing commit, most reviewers missed it. Ten of thirteen attempts, across seven of eight agents, failed: eight found nothing, two reached the cost of the call but not its effect. The allocation is not in those lines, and a review that reads only the diff cannot see it. Claude Opus 5 identified it in both attempts by following the call into the wallet crate, and derived the consequence: after enough rebuilds “real change sits at index 300+ with nothing used below it. A later restore-from-backup scan with a stop-gap of 25 walks indices 0..25, finds nothing, and stops — the user’s change appears to be gone. For a hardware wallet that is the worst possible failure mode, and it is silent.”
Kimi K3 reached the same conclusion in one of its two attempts (“send_to reveals a fresh change spk (persisted) and mark_useds it … every rebuild with change burns another change index”) and in the other filed the same function under “Smaller items” as a performance note. Same model, same checkout, same prompt. One or two runs per condition is not enough to rank models against each other.
Visibility matters more than capability. The January change was caught more often in the single-commit condition because that diff contained both the caller and the allocation. A 27-billion-parameter model on a laptop found it there. Where the callee was out of sight, only the strongest reviewer followed the call.
Capability sets the floor. Across every condition, the three local models found the defect once between them, and four of their reviews approved the work outright. The two tiers of one commercial family, shown identical material, split: the larger found it in five of six attempts, the smaller in one. On the control commit, three agents correctly raised no objection; the two that objected had read past the commit into the surrounding design and found the real defect there. Kimi K3 also read beyond the diff and still did not reach it.
These are 2026 models reviewing 2025 code, with one or two samples per cell, and the reviewers are software, not people.
7 · Recommendations
-
Separate planning from committing wherever an interface previews an operation. Anything the user reviews before confirming (a fee, a coin selection, an address) should come from a function that reserves nothing, with a separate function that commits.
-
Initialise the wallet once, through a handle. Deferred setup forced every query to take
&mut self. When the application needs a wallet it should receive a handle whose construction has done the work. Queries then read as queries, and a&mutmethod is worth noticing again. -
Make persistence structural. The dropped synchronisation record was possible because a function could return a subset of what it produced. The storage API should persist everything an operation generates, with the loudly-named escape hatch as the only way to discard state on purpose.
-
Nothing on the display path may commit state. A function called while drawing the interface should read already-computed values. Enforce this at the Rust-to-Dart bridge: a getter published across it should be demonstrably unable to reach the wallet.
-
Read the call graph once per change that crosses the interface boundary. For any change moving logic between interface and wallet, one reviewer should answer in writing: which interface-triggered calls write, and to what? Section 6 suggests this can be delegated to tooling if it runs over the finished feature rather than commit by commit, and uses the most capable reviewer available.
-
Name committing operations as commitments.
send_toread as “build me a transaction”. Its replacements areplan_sendandcommit_send. Anything that allocates from a keychain or writes to the database should say so in its name.
Sources
Every commit, date, quotation and line cited above was checked against the project’s history and GitHub records on 27 August 2026. The review experiment’s method, raw transcripts and the classification of every run are preserved.