perf(lite-backend): encode note addresses from pk_d instead of deriving

note_address() re-derived a note's payment address from its viewing key
and diversifier (a group hash, an ivk hash and a 252-bit scalar
multiplication) for every note in `notes` and `list` and for each new
note in the block scan. The z-balance sums did the same derivation in
their address filter, so `balance`, which sums four balances for each of
Z z-addresses, cost Z x (N + 2U) derivations for N notes, U of them
unspent. do_list_notes also encoded up to Z + Z^2 addresses to find the
spendable ones and cloned that set once per transaction.

Now:
- note_address() encodes the note's stored pk_d with its diversifier.
  Both decryption and wallet load build that pk_d from the same key and
  diversifier, so the string is byte-identical;
- the three z-balance sums share one address filter. The per-address
  sums behind `balance` compare (diversifier, pk_d) with each z-address
  directly; any other address string keeps the old exact string compare;
- those sums work from a copy of the z-addresses whose lock is released
  before any note is read, so the txs-then-zkeys lock order that sync
  uses is unchanged;
- do_list_notes builds the spendable-address set with Z encodes and
  borrows it.

No output changes: balances and the notes/list/balance JSON are
byte-identical. The address filter no longer contains an unwrap (it
could not fail).

Tests compare against verbatim copies of the old functions over
randomized chains (note addresses, also after a reload; every filtered
balance for own, diversified, foreign and junk address strings; the
per-address balances; the spendable set), and a concurrency test fails
with a deadlock if the zkeys lock is held across the sums. A
differential run against the unmodified backend compared per-address
balances and the JSON after every operation, through reorgs and reloads,
and the old build re-saves the new files byte for byte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 09:50:03 -05:00
co-authored by Claude Opus 5.5
parent d28cc0da31
commit bd4c06dfd0
3 changed files with 739 additions and 56 deletions
@@ -659,13 +659,14 @@ impl LightClient {
let wallet = self.wallet.read().unwrap();
// Collect z addresses
let z_addresses = wallet.get_all_zaddresses().iter().map(|zaddress| {
let z_addresses = wallet.z_balances_by_address().into_iter()
.map(|(address, zbalance, unconfirmed, verified_zbalance, spendable_zbalance)| {
object!{
"address" => zaddress.clone(),
"zbalance" => wallet.zbalance(Some(zaddress.clone())),
"unconfirmed" => wallet.unconfirmed_zbalance(Some(zaddress.clone())),
"verified_zbalance" => wallet.verified_zbalance(Some(zaddress.clone())),
"spendable_zbalance" => wallet.spendable_zbalance(Some(zaddress.clone()))
"address" => address,
"zbalance" => zbalance,
"unconfirmed" => unconfirmed,
"verified_zbalance" => verified_zbalance,
"spendable_zbalance" => spendable_zbalance
}
}).collect::<Vec<JsonValue>>();
@@ -847,15 +848,13 @@ impl LightClient {
let wallet = self.wallet.read().unwrap();
// First, collect all extfvk's that are spendable (i.e., we have the private key)
let spendable_address: HashSet<String> = wallet.get_all_zaddresses().iter()
.filter(|address| wallet.have_spending_key_for_zaddress(address))
.map(|address| address.clone())
.collect();
let spendable_address_set: HashSet<String> = wallet.spendable_zaddress_set();
let spendable_address: &HashSet<String> = &spendable_address_set;
// Collect Sapling notes
wallet.txs.read().unwrap().iter()
.flat_map( |(txid, wtx)| {
let spendable_address = spendable_address.clone();
let spendable_address: &HashSet<String> = spendable_address;
wtx.notes.iter().filter_map(move |nd|
if !all_notes && nd.spent.is_some() {
None