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
@@ -105,6 +105,33 @@ impl ToBase58Check for [u8] {
}
}
/// Which notes a z-balance sum counts. `Addr` compares (diversifier, pk_d) directly. Because
/// encode_payment_address is injective on (diversifier, pk_d), it selects exactly the notes whose
/// old derived string == encode(pa). `Str` keeps the old exact string compare (on the cheap
/// note_address) for arbitrary caller strings, so no decoding and no new panic paths.
enum ZAddrFilter {
All,
Addr(PaymentAddress<Bls12>),
Str(String),
}
impl ZAddrFilter {
fn from_opt(addr: &Option<String>) -> Self {
match addr {
Some(a) => ZAddrFilter::Str(a.clone()),
None => ZAddrFilter::All,
}
}
fn matches(&self, hrp: &str, nd: &SaplingNoteData) -> bool {
match self {
ZAddrFilter::All => true,
ZAddrFilter::Addr(pa) => nd.diversifier == pa.diversifier && nd.note.pk_d == pa.pk_d,
ZAddrFilter::Str(a) => LightWallet::note_address(hrp, nd).as_ref() == Some(a),
}
}
}
pub struct LightWallet {
// Is the wallet encrypted? If it is, then when writing to disk, the seed is always encrypted
// and the individual spending keys are not written
@@ -490,10 +517,12 @@ impl LightWallet {
}
pub fn note_address(hrp: &str, note: &SaplingNoteData) -> Option<String> {
match note.extfvk.fvk.vk.into_payment_address(note.diversifier, &JUBJUB) {
Some(pa) => Some(encode_payment_address(hrp, &pa)),
None => None
}
// note.pk_d == g_d(diversifier) * ivk(note.extfvk) by construction: decryption
// (parse_note_plaintext_without_memo) and SaplingNoteData::read (data.rs:158) both build
// it that way. So this encodes exactly what into_payment_address() would, without the
// group hash, ivk hash and 252-bit scalar multiplication.
let pa = PaymentAddress { pk_d: note.note.pk_d.clone(), diversifier: note.diversifier };
Some(encode_payment_address(hrp, &pa))
}
pub fn get_birthday(&self) -> u64 {
@@ -1065,33 +1094,26 @@ impl LightWallet {
pub fn zbalance(&self, addr: Option<String>) -> u64 {
let unconfirmed_balance = self.unconfirmed_zbalance(addr.clone());
let confirmed_balance = self.confirmed_zbalance_f(&ZAddrFilter::from_opt(&addr));
let confirmed_balance = self.txs.read().unwrap()
confirmed_balance + unconfirmed_balance
}
// Unspent (and not pending-spent) note value matching `f`. The spent checks run first so spent
// notes never reach the address filter; both are pure, so the sum equals the old
// filter(addr).map(if unspent {v} else {0}).sum().
fn confirmed_zbalance_f(&self, f: &ZAddrFilter) -> u64 {
let hrp = self.config.hrp_sapling_address();
self.txs.read().unwrap()
.values()
.map(|tx| {
tx.notes.iter()
.filter(|nd| {
match addr.as_ref() {
Some(a) => *a == encode_payment_address(
self.config.hrp_sapling_address(),
&nd.extfvk.fvk.vk
.into_payment_address(nd.diversifier, &JUBJUB).unwrap()
),
None => true
}
})
.map(|nd| {
if nd.spent.is_none() && nd.unconfirmed_spent.is_none() {
nd.note.value
} else {
0
}
})
.filter(|nd| nd.spent.is_none() && nd.unconfirmed_spent.is_none())
.filter(|nd| f.matches(hrp, nd))
.map(|nd| nd.note.value)
.sum::<u64>()
})
.sum::<u64>();
confirmed_balance + unconfirmed_balance
.sum::<u64>()
}
// Get all (unspent) utxos. Unconfirmed spent utxos are included
@@ -1119,10 +1141,15 @@ impl LightWallet {
}
pub fn verified_zbalance(&self, addr: Option<String>) -> u64 {
self.verified_zbalance_f(&ZAddrFilter::from_opt(&addr))
}
fn verified_zbalance_f(&self, f: &ZAddrFilter) -> u64 {
let anchor_height = match self.get_target_height_and_anchor_offset() {
Some((height, anchor_offset)) => height - anchor_offset as u32 ,
None => return 0,
};
let hrp = self.config.hrp_sapling_address();
self.txs
.read()
@@ -1133,16 +1160,7 @@ impl LightWallet {
tx.notes
.iter()
.filter(|nd| nd.spent.is_none() && nd.unconfirmed_spent.is_none())
.filter(|nd| { // TODO, this whole section is shared with verified_balance. Refactor it.
match addr.as_ref() {
Some(a) => *a == encode_payment_address(
self.config.hrp_sapling_address(),
&nd.extfvk.fvk.vk
.into_payment_address(nd.diversifier, &JUBJUB).unwrap()
),
None => true
}
})
.filter(|nd| f.matches(hrp, nd))
.map(|nd| nd.note.value)
.sum::<u64>()
} else {
@@ -1153,7 +1171,14 @@ impl LightWallet {
}
pub fn spendable_zbalance(&self, addr: Option<String>) -> u64 {
self.spendable_zbalance_f(&ZAddrFilter::from_opt(&addr))
}
// Lock order: txs.read, then zkeys.read inside have_spendingkey_for_extfvk (same order as
// scan_block). Never call this while holding a zkeys guard.
fn spendable_zbalance_f(&self, f: &ZAddrFilter) -> u64 {
let anchor_height = self.get_anchor_height();
let hrp = self.config.hrp_sapling_address();
self.txs
.read()
@@ -1164,20 +1189,11 @@ impl LightWallet {
tx.notes
.iter()
.filter(|nd| nd.spent.is_none() && nd.unconfirmed_spent.is_none())
.filter(|nd| f.matches(hrp, nd))
.filter(|nd| {
// Check to see if we have this note's spending key.
self.have_spendingkey_for_extfvk(&nd.extfvk)
})
.filter(|nd| { // TODO, this whole section is shared with verified_balance. Refactor it.
match addr.as_ref() {
Some(a) => *a == encode_payment_address(
self.config.hrp_sapling_address(),
&nd.extfvk.fvk.vk
.into_payment_address(nd.diversifier, &JUBJUB).unwrap()
),
None => true
}
})
.map(|nd| nd.note.value)
.sum::<u64>()
} else {
@@ -1227,6 +1243,45 @@ impl LightWallet {
}
}
/// Per-zkey z balances for do_balance, in zkeys order:
/// (address, zbalance, unconfirmed, verified_zbalance, spendable_zbalance).
/// Equal to calling zbalance/unconfirmed_zbalance/verified_zbalance/spendable_zbalance with
/// Some(encoded zaddress), in that order, without re-deriving any note's address.
pub fn z_balances_by_address(&self) -> Vec<(String, u64, u64, u64, u64)> {
let hrp = self.config.hrp_sapling_address();
// Snapshot, then RELEASE the zkeys guard before any txs lock: scan_block holds txs.write
// while taking zkeys.read/write (ensure_hd_zaddresses -> add_zaddr), and
// spendable_zbalance_f re-reads zkeys. Holding zkeys here would invert the lock order
// and re-enter a writer-preferring RwLock (deadlock).
let addrs: Vec<(String, PaymentAddress<Bls12>)> = self.zkeys.read().unwrap().iter()
.map(|zk| (encode_payment_address(hrp, &zk.zaddress), zk.zaddress.clone()))
.collect();
addrs.into_iter().map(|(address, pa)| {
let f = ZAddrFilter::Addr(pa);
let zbalance = {
let unconfirmed_balance = self.unconfirmed_zbalance(Some(address.clone()));
self.confirmed_zbalance_f(&f) + unconfirmed_balance
};
let unconfirmed = self.unconfirmed_zbalance(Some(address.clone()));
let verified = self.verified_zbalance_f(&f);
let spendable = self.spendable_zbalance_f(&f);
(address, zbalance, unconfirmed, verified, spendable)
}).collect()
}
/// Encoded z-addresses whose FIRST zkey with that address has a spending key. Same set as
/// get_all_zaddresses() filtered by have_spending_key_for_zaddress (Z encodes, not Z + Z^2).
pub fn spendable_zaddress_set(&self) -> HashSet<String> {
let hrp = self.config.hrp_sapling_address();
let mut first: HashMap<String, bool> = HashMap::new();
for zk in self.zkeys.read().unwrap().iter() {
first.entry(encode_payment_address(hrp, &zk.zaddress))
.or_insert_with(|| zk.have_spending_key());
}
first.into_iter().filter(|(_, spendable)| *spendable).map(|(a, _)| a).collect()
}
fn add_toutput_to_wtx(&self, height: i32, timestamp: u64, txid: &TxId, vout: &TxOut, n: u64) {