zcash/wallet: decrypt a Sapling note's plaintext and note in one pass

SaplingNotePlaintext::decrypt(ciphertext, ivk, epk, cmu) derives
pk_d = [ivk]g_d to check the note commitment, then throws it away. The
wallet then calls ivk.address(d), which derives pk_d again, and
plaintext.note(ivk), which derives it a third time. Each derivation is a
Jubjub scalar multiplication, ~115us, measured. Including the key
agreement and the commitment check, the whole sequence is ~775us per
note.

decrypt_with_note() runs the same decryption and checks and returns the
note built from that pk_d. ivk_to_pkd succeeding already implies a
valid diversifier, which is all ivk.address() checks on top. It gets its
own name rather than a decrypt() overload: an overload taking a fifth
argument by non-const reference would capture calls meant for the
(epk, esk, pk_d, cmu) variant. The cmu check stays.

GetFilteredNotes and FindMySaplingNotes' spending-key loop use it. That
saves two of the three derivations per note in every balance walk, and
one per found note during scans and rescans (trial decryption of
outputs that aren't ours still fails at the AEAD, as before).
FindMySaplingNotes also no longer copies each OutputDescription.

New tests: MatchesDecryptThenNote checks decrypt_with_note field for
field against decrypt() + note(ivk), over default and diversified
addresses, a wrong ivk and a wrong cmu. FindMySaplingNotes pins what
discovery returns: notes per key, diversified addresses to register,
foreign outputs ignored. Both pass before and after the switch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 08:16:09 -05:00
co-authored by Claude Opus 5.5
parent bfec5c74ae
commit 7ddd03d86d
4 changed files with 154 additions and 15 deletions
+94
View File
@@ -494,3 +494,97 @@ TEST_F(WalletNoteCacheEncrypted, UnlockedWalletFindsSpendingKeys) {
EXPECT_TRUE(sk.DefaultAddress() == paC);
EXPECT_FALSE(wallet.GetSaplingSpendingKey(TestKey(9).expsk.full_viewing_key(), sk));
}
namespace {
// The default address plus a few diversified addresses of the same key.
std::vector<SaplingPaymentAddress> AddressesOf(const SaplingExtendedSpendingKey& sk)
{
std::vector<SaplingPaymentAddress> addrs = {sk.DefaultAddress()};
for (unsigned char start : {7, 90, 201}) {
diversifier_index_t j;
j.begin()[0] = start;
auto found = sk.ToXFVK().Address(j);
if (found)
addrs.push_back(found->second);
}
return addrs;
}
}
// decrypt_with_note returns exactly decrypt() plus decrypt()->note(ivk), and fails exactly when they do.
TEST(SaplingDecryptWithNote, MatchesDecryptThenNote) {
auto sk = TestKey(0);
const SaplingIncomingViewingKey ivk = sk.expsk.full_viewing_key().in_viewing_key();
const SaplingIncomingViewingKey otherIvk = TestKey(1).expsk.full_viewing_key().in_viewing_key();
auto addrs = AddressesOf(sk);
ASSERT_GE(addrs.size(), 3u) << "need diversified addresses too";
uint64_t value = 100;
for (const SaplingPaymentAddress& pa : addrs) {
OutputDescription od = MakeOutput(pa, ++value);
auto legacyPt = SaplingNotePlaintext::decrypt(od.encCiphertext, ivk, od.ephemeralKey, od.cm);
ASSERT_TRUE(static_cast<bool>(legacyPt));
auto legacyNote = legacyPt->note(ivk);
ASSERT_TRUE(static_cast<bool>(legacyNote));
auto both = SaplingNotePlaintext::decrypt_with_note(od.encCiphertext, ivk, od.ephemeralKey, od.cm);
ASSERT_TRUE(static_cast<bool>(both));
const SaplingNotePlaintext& pt = both->first;
const SaplingNote& note = both->second;
EXPECT_TRUE(pt.d == legacyPt->d);
EXPECT_EQ(legacyPt->rcm, pt.rcm);
EXPECT_EQ(legacyPt->value(), pt.value());
EXPECT_TRUE(pt.memo() == legacyPt->memo());
EXPECT_TRUE(note.d == legacyNote->d);
EXPECT_EQ(legacyNote->pk_d, note.pk_d);
EXPECT_EQ(legacyNote->r, note.r);
EXPECT_EQ(legacyNote->value(), note.value());
EXPECT_TRUE(SaplingPaymentAddress(note.d, note.pk_d) == pa);
EXPECT_EQ(value, note.value());
EXPECT_FALSE(static_cast<bool>(SaplingNotePlaintext::decrypt_with_note(od.encCiphertext, otherIvk, od.ephemeralKey, od.cm)));
uint256 badCm = od.cm;
badCm.begin()[0] ^= 1;
EXPECT_FALSE(static_cast<bool>(SaplingNotePlaintext::decrypt(od.encCiphertext, ivk, od.ephemeralKey, badCm)));
EXPECT_FALSE(static_cast<bool>(SaplingNotePlaintext::decrypt_with_note(od.encCiphertext, ivk, od.ephemeralKey, badCm)));
}
}
// What FindMySaplingNotes discovers: notes to a spending key's default and diversified addresses, and to
// a viewing-key-only address, each with the key that found it; a diversified address the wallet hasn't
// seen yet is reported for registration; outputs to other wallets are ignored.
TEST(WalletNoteCacheDiscovery, FindMySaplingNotes) {
NoteCacheWallet wallet;
auto skA = TestKey(0), skB = TestKey(1);
const SaplingIncomingViewingKey ivkA = skA.expsk.full_viewing_key().in_viewing_key();
const SaplingIncomingViewingKey ivkB = skB.expsk.full_viewing_key().in_viewing_key();
auto addrsA = AddressesOf(skA);
const SaplingPaymentAddress paB = skB.DefaultAddress();
LOCK2(cs_main, wallet.cs_wallet);
ASSERT_TRUE(wallet.AddSaplingZKey(skA, addrsA[0]));
ASSERT_TRUE(wallet.AddSaplingIncomingViewingKey(ivkB, paB));
CTransaction tx = MakeTx({MakeOutput(addrsA[0], 1), MakeOutput(addrsA[1], 2), MakeOutput(paB, 3),
MakeOutput(TestKey(9).DefaultAddress(), 4), MakeOutput(addrsA[2], 5)});
auto found = wallet.FindMySaplingNotes(tx);
const mapSaplingNoteData_t& notes = found.first;
ASSERT_EQ(4u, notes.size());
EXPECT_TRUE(notes.at(SaplingOutPoint(tx.GetHash(), 0)).ivk == ivkA);
EXPECT_TRUE(notes.at(SaplingOutPoint(tx.GetHash(), 1)).ivk == ivkA);
EXPECT_TRUE(notes.at(SaplingOutPoint(tx.GetHash(), 2)).ivk == ivkB);
EXPECT_EQ(0u, notes.count(SaplingOutPoint(tx.GetHash(), 3)));
EXPECT_TRUE(notes.at(SaplingOutPoint(tx.GetHash(), 4)).ivk == ivkA);
for (const auto& item : notes) {
EXPECT_FALSE(static_cast<bool>(item.second.nullifier));
EXPECT_TRUE(item.second.witnesses.empty());
EXPECT_EQ(-1, item.second.witnessHeight);
}
const SaplingIncomingViewingKeyMap& toAdd = found.second;
ASSERT_EQ(2u, toAdd.size()) << "the two diversified addresses, not the registered default one";
EXPECT_TRUE(toAdd.at(addrsA[1]) == ivkA);
EXPECT_TRUE(toAdd.at(addrsA[2]) == ivkA);
}