ZCA-229 Filter PIR endpoints by exact snapshot match before delegation#27
Merged
Merged
Conversation
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
This was referenced Apr 26, 2026
greg0x
pushed a commit
that referenced
this pull request
Apr 26, 2026
#27) 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
added a commit
that referenced
this pull request
May 3, 2026
#27) 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
added a commit
that referenced
this pull request
May 4, 2026
#27) 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
added a commit
that referenced
this pull request
May 5, 2026
#27) 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
added a commit
that referenced
this pull request
May 5, 2026
#27) 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
added a commit
that referenced
this pull request
May 5, 2026
#27) 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Linear: ZCA-229
Recreated from #26 against the correct base branch (
shielded-vote-2.4.10rather thanvalar/orchard-0.13). Same change, plus SwiftLint CI fixes folded in (see "Lint" below).The voting service config publishes an
expectedSnapshotHeightper round and a list of PIR endpoints. Before this change,buildAndProveDelegationtook a singlepirServerUrland 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
PirSnapshotResolverinZcashLightClientKitthat, given the configured endpoints and the round's expected snapshot height:GET /rootin parallel.RootInfo.heightis 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.PirSnapshotResolverError.noMatchingEndpointcarrying 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.buildAndProveDelegationis nowasync throwsand takespirEndpoints: [String] + expectedSnapshotHeight: UInt64(plus an optional injectedPirSnapshotProbingfor tests) instead of a singlepirServerUrl. It resolves to a single URL via the resolver before calling the Rust FFI.The
zodl-ioscaller side of this change is in valargroup/zodl-ios#47 (depends on this one merging + an SPM bump).Lint
The previous PR's SwiftLint CI surfaced 3 errors + 2 warnings on
PirSnapshotResolver.swiftthat the newer SwiftLint I have locally doesn't emit. All five are fixed in this commit:string_concatenation:url.trimmedTrailingSlash + "/root"→"\(url.trimmedTrailingSlash)/root".string_concatenationon the multi-lineerrorDescription: split intolead/tailconstants then interpolated.identifier_name× 2:case .matching(let h)/case .mismatched(let h)→let height(also self-documenting).pattern_matching_keywords× 2:case .noMatchingEndpoint(let expected, let details)→case let .noMatchingEndpoint(expected, details).The remaining SwiftLint warnings reported by CI for this PR are all in pre-existing files I did not touch.
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).==policy), empty endpoint list rejected, error description contains expected snapshot + endpoint URLs, probe receives correct URLs/expected height.HTTPPirSnapshotProbeTestsexercisesHTTPPirSnapshotProbe.probe(...)end-to-end through aURLProtocolstub: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,/rootpath appended (with and without trailing slash on base), invalid URL string →.unreachable(no crash).RootInfoJSON shape (num_ranges/pir_depthsnake_case,height: Option<u64>) againstvote-nullifier-pir.Made with Cursor