Skip to content

Accelerate batched trial decryption with GLV endomorphism windows - #530

Closed
LukasKorba wants to merge 1 commit into
zcash:mainfrom
LukasKorba:endo-batched-trial-decryption
Closed

Accelerate batched trial decryption with GLV endomorphism windows#530
LukasKorba wants to merge 1 commit into
zcash:mainfrom
LukasKorba:endo-batched-trial-decryption

Conversation

@LukasKorba

@LukasKorba LukasKorba commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Batched trial decryption is the hot loop of light-client scanning: every compact output's ephemeral key is multiplied by each of the wallet's incoming viewing keys. This PR accelerates that loop using the Pallas cube-root endomorphism, consuming the BatchDomain::batch_ka_agree_dec hook added in zcash/zcash_note_encryption#13 and released in zcash_note_encryption 0.4.2 (#14).

(Strictly speaking, the GLV paper only mentioned using the Frobenius endomorphism, but the extension to cubic endomorphisms is obvious, and commonly also called GLV.)

What changes

A new private endo module implements GLV decomposition for the Pallas scalar field: k = k1 + k2·λ (mod r_P) with both halves ≤ 2^127, so [k] P = [k1] P + [k2] φ(P) runs as a Straus ladder with shared doublings (~128 doublings + ~52 window-4 additions instead of a ~255-doubling full-width walk). The batched pipeline amortizes everything that can be amortized:

  • batch_epk now prepares every ephemeral key in the batch with a GLV odd-multiples window ({1,3,5,7}·P and {1,3,5,7}·φ(P), affine), normalized across the whole batch with a single shared inversion — where per-item preparation pays one inversion (or one wNAF table build) per key.
  • batch_ka_agree_dec decomposes and wNAF-recodes the viewing key once per batch, then consumes each ephemeral key's window with the shared-doubling ladder. Each window is reused by every viewing key's multiplication against it (wallets scan with at least two ivks — external and internal scope).
  • Since the BatchDomain impl is generic over the domain policy, OrchardDomain and IronwoodDomain both get the fast path from one implementation.

The per-output entry points (try_note_decryption etc.) are untouched: prepare_epk still builds the group::Wnaf table and multiplies exactly as before. There are no public API changes; everything new is pub(crate). The minimum zcash_note_encryption version becomes 0.4.2 (semver-compatible).

Correctness

Shared secrets are byte-identical to the per-item path, and the tests pin this from three directions:

  • In-module KATs (src/endo.rs): the GLV constants are re-verified from scratch (decompose_reconstructs checks k1 + k2·λ ≡ k and the ≤ 2^127 bound over full-width scalars and edge cases — wrong constants cannot pass); the φ↔λ pairing is checked on the real curve; and the ladder is checked byte-identical to the group's own scalar multiplication over batch-built and individually-built windows, including 0, ±1, and λ.
  • Pipeline equality (src/note_encryption.rs): batch::try_compact_note_decryption must produce exactly the per-item results — hits on multiple viewing keys (external + internal scope), misses, and an undecodable ephemeral key lane — for both OrchardDomain and IronwoodDomain.
  • Agreement equality: batch_ka_agree_dec must produce byte-identical shared secrets to per-item ka_agree_dec on hit and miss lanes alike, for both preparation routes (batch-built GLV windows and individually-prepared wNAF inputs, which take a per-item fallback arm).

The full existing suite passes unchanged.

Timing

Like the existing group::Wnaf-based preparation this complements (PreparedNonZeroScalar / PreparedNonIdentityBase), the new path is variable-time with respect to the scalar. It is used only for trial decryption with the wallet's own incoming viewing keys, so this matches the crate's existing posture for this operation.

Performance

cargo bench --bench note_decryption on an Apple M4 Pro (this branch vs. current main, back-to-back runs against a saved criterion baseline; medians):

benchmark (2 ivks × N outputs) main this PR change
batch-…/compact-invalid/10 1.015 ms 0.628 ms −38.2%
batch-…/compact-invalid/50 5.048 ms 3.092 ms −38.7%
batch-…/compact-invalid/100 10.158 ms 6.133 ms −39.6%
batch-…/invalid/10 1.025 ms 0.630 ms −38.4%
batch-…/invalid/50 5.120 ms 3.079 ms −39.9%
batch-…/invalid/100 10.152 ms 6.185 ms −39.1%
batch-…/valid/{10,50,100} −5.5% / −4.6% / −3.6%
batch-…/compact-valid/{10,50,100} −4.9% / −4.3% / −6.7%

The invalid groups are the trial-decryption miss path — what a scanning wallet does for essentially every output on chain — and improve by ~1.6×. The valid groups fully decrypt every output, where key agreement is a small share of the work next to the note-commitment and esk checks. The per-item groups (note-decryption/*, compact-note-decryption/invalid) read −2.8% to +1.2% (noise band), as expected — that path is untouched.

The same technique running in a production mobile sync engine (where it was developed and measured first) cut trial-decryption CPU by ~21% and end-to-end scan-call wall time by ~15% on Apple Silicon devices.

Batched trial decryption multiplies every ephemeral key in a batch by
each of the wallet's incoming viewing keys. This replaces the batched
pipeline's per-key wNAF preparation with GLV odd-multiples windows
built across the whole batch under a single shared normalization, and
consumes them with a shared-doubling Straus ladder over the
endomorphism split k = k1 + k2*lambda, decomposing each viewing key
once per batch (via BatchDomain::batch_ka_agree_dec, new in
zcash_note_encryption 0.4.2).

Shared secrets are byte-identical to the per-item path, which is
untouched; the GLV constants are re-verified from scratch in-crate,
and the batched pipeline is tested equal to the per-item one for both
the Orchard and Ironwood domains, including miss lanes and undecodable
ephemeral keys. Like the existing Wnaf-based preparation, the ladder
is variable-time with respect to the (wallet-local) viewing key
scalar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
LukasKorba added a commit to LukasKorba/pasta_curves that referenced this pull request Jul 13, 2026
Both curves carry the cube-root endomorphism already exposed as
CurveExt::endo(); this adds the multiplication machinery that exploits
it: GLV decomposition (short lattice basis + Babai rounding), width-4
wNAF recoding, a precomputed table of odd multiples of P and phi(P),
and a shared-doubling Straus ladder over the split.

The API is additive and variable-time (documented): the native
constant-time Mul implementations are untouched, per review guidance
on zcash/orchard#530 to host this machinery at the curve layer with
_glv-suffixed naming. The three cost centers are independently
reusable: Table (per point, with Table::batch amortizing one field
inversion across a whole batch), Decomposed (per scalar), and the
ladder itself.

The lattice constants are re-verified in-crate against each curve's
own lambda (= Scalar::ZETA) by field arithmetic alone, and the
decompose_reconstructs tests prove k1 + k2*lambda == k with both
halves <= 2^127 over full-width scalars and edge cases - wrong
constants cannot pass. Pallas constants match the reference
implementation on zcash/orchard#530; Vesta constants are derived by
the same lattice reduction (validated by reproducing the Pallas set
bit-for-bit first).

Benchmarks (M4, criterion): one-shot mul_glv ~34us vs ~96.5us native
(2.8x); with a reused table ~26us (3.7x); batched table build 1.8us
per point vs 7.7us solo. Pallas and Vesta within noise of each other.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@nuttycom

Copy link
Copy Markdown
Contributor

Subsumed by #539

@nuttycom nuttycom closed this Jul 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants