Skip to content

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

Merged
greg0x merged 1 commit into
shielded-vote-2.4.10from
roman/zca-229-pir-snapshot-match-v2
Apr 26, 2026
Merged

ZCA-229 Filter PIR endpoints by exact snapshot match before delegation#27
greg0x merged 1 commit into
shielded-vote-2.4.10from
roman/zca-229-pir-snapshot-match-v2

Conversation

@p0mvn

@p0mvn p0mvn commented Apr 26, 2026

Copy link
Copy Markdown

Note (2026-04-26): This PR's build job is currently red because the shielded-vote-2.4.10 base branch was missing the voting-circuits and zcash_voting [patch.crates-io] entries (and had the wrong orchard patch source). Fix in #28 — once that lands, this PR's build will turn green via the FFI cache.

Summary

Linear: ZCA-229

Recreated from #26 against the correct base branch (shielded-vote-2.4.10 rather than valar/orchard-0.13). Same change, plus SwiftLint CI fixes folded in (see "Lint" below).

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 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.swift that 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_concatenation on the multi-line errorDescription: split into lead/tail constants 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).
  • 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.

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

@greg0x greg0x left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@greg0x greg0x merged commit 6678f6e into shielded-vote-2.4.10 Apr 26, 2026
3 of 4 checks passed
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
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