Skip to content

ZCA-229 Filter PIR endpoints by exact snapshot match before delegation#26

Closed
p0mvn wants to merge 1 commit into
valar/orchard-0.13from
roman/zca-229-pir-snapshot-match
Closed

ZCA-229 Filter PIR endpoints by exact snapshot match before delegation#26
p0mvn wants to merge 1 commit into
valar/orchard-0.13from
roman/zca-229-pir-snapshot-match

Conversation

@p0mvn

@p0mvn p0mvn commented Apr 26, 2026

Copy link
Copy Markdown

Summary

Linear: ZCA-229

The voting service config publishes an expectedSnapshotHeight per round and a list of PIR endpoints. Before this change, buildAndProveDelegation took a single pirServerUrl and the iOS caller forwarded the first configured endpoint blindly — so a PIR server stuck on an older snapshot (or accidentally ahead of the round) would silently produce a delegation proof that the prover/audit cannot accept.

This adds a PirSnapshotResolver in ZcashLightClientKit that, given the configured endpoints and the round's expected snapshot height:

  • Probes every endpoint's GET /root in parallel.
  • Keeps only those whose RootInfo.height is exactly equal (==) to the expected snapshot height. == rather than >= because a PIR server ahead of the round is just as wrong as one behind — both will produce proofs that don't match the audit-anchored snapshot.
  • Picks the first matching endpoint in config order (deterministic, no hidden re-ordering).
  • Hard-fails with PirSnapshotResolverError.noMatchingEndpoint carrying per-endpoint diagnostics (matched height, mismatched height, missing height, transport error) when nothing matches, so callers can surface a clear "out of sync, try in a few minutes" message instead of producing a junk proof.

VotingRustBackend.buildAndProveDelegation is now async throws and takes pirEndpoints: [String] + expectedSnapshotHeight: UInt64 (plus an optional injected PirSnapshotProbing for tests) instead of a single pirServerUrl. It resolves to a single URL via the resolver before calling the Rust FFI.

The zodl-ios caller side of this change is in a separate PR (depends on this one merging + an SPM bump).

Test plan

  • xcodebuild test -scheme ZcashLightClientKit -only-testing:OfflineTests/PirSnapshotResolverTests -only-testing:OfflineTests/HTTPPirSnapshotProbeTests -only-testing:OfflineTests/HTTPPirSnapshotProbeWireShapeTests — 21/21 pass on iOS Simulator (iPhone 17 Pro).
  • Resolver-level tests cover: exact match accepted, all-mismatched hard-fails with diagnostics, missing-height treated as not-matching, unreachable treated as not-matching, first-matching-in-config-order chosen, mismatch-then-match selection, height-above-expected rejected (regression for the == policy), empty endpoint list rejected, error description contains expected snapshot + endpoint URLs, probe receives correct URLs/expected height.
  • HTTPPirSnapshotProbeTests exercises HTTPPirSnapshotProbe.probe(...) end-to-end through a URLProtocol stub: height == expected → .matching, height < expected → .mismatched, height > expected → .mismatched (the regression that locks in ==), height == null → .missingHeight, non-200 response → .unreachable("...503..."), malformed JSON → .unreachable("...decode..."), transport error → .unreachable, /root path appended (with and without trailing slash on base), invalid URL string → .unreachable (no crash).
  • Wire-shape test locks in the RootInfo JSON shape (num_ranges / pir_depth snake_case, height: Option<u64>) against vote-nullifier-pir.
  • End-to-end voting flow against a deployed round once the zodl-ios PR is wired up.

Notes

  • Per the previous review pass: the HTTPPirSnapshotProbe.timeout field was dropped (it was stored but unread once the session was constructed) and the doc comment now states explicitly that timeout is ignored when a caller-supplied URLSession is passed.
  • The == policy was an explicit product decision; the resolver and probe now name everything in terms of "matching" / "mismatched" rather than "fresh" / "stale" so that semantics aren't ambiguous to readers.

Made with Cursor

The voting service config now ships an `expectedSnapshotHeight` for each
round and a list of PIR endpoints. Previously the SDK forwarded the first
configured endpoint blindly, so a PIR server stuck on an older (or
unexpectedly newer) snapshot would silently produce a delegation proof
the prover/audit cannot accept.

`PirSnapshotResolver` probes every configured endpoint's `GET /root` and
keeps only those whose `RootInfo.height` is **exactly equal** to the
round's expected snapshot height (== rather than >=, since a PIR server
ahead of the round is just as wrong as one behind it). The first
matching endpoint in config order is chosen. If none match, resolution
hard-fails with `noMatchingEndpoint` and per-endpoint diagnostics so
callers can surface a clear "out of sync, try later" message.

`VotingRustBackend.buildAndProveDelegation` now takes
`pirEndpoints: [String]` + `expectedSnapshotHeight: UInt64` (with an
optional injected resolver for tests) and resolves to a single URL
before calling the Rust FFI.

Made-with: Cursor
@p0mvn

p0mvn commented Apr 26, 2026

Copy link
Copy Markdown
Author

Recreating against the correct base branch shielded-vote-2.4.10 (was incorrectly opened against valar/orchard-0.13). Same change, plus SwiftLint CI fixes (string concatenation → interpolation, let h identifier-name fixes, pattern_matching_keywords cleanup). Replaced by #TBD.

@p0mvn p0mvn closed this Apr 26, 2026
@p0mvn p0mvn deleted the roman/zca-229-pir-snapshot-match branch April 26, 2026 03:10
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.

1 participant