Conversation
This was referenced Apr 3, 2026
1cd23f9 to
a9e50c4
Compare
- Checkpoints updated - Changelog updated
[MOB-1037] Release 2.4.9
Add spend-client, spend-types from sync-nullifier-pir (ab81f98). Enable the sync-nullifier-pir feature on zcash_client_sqlite. All [patch.crates-io] entries point to valargroup/librustzcash at 9861871 (sync-pir-0.19.x branch), which carries PIR spent-note tracking on top of the 0.19.x release line. Related upstream PR: zcash/librustzcash#2268
Integrate nullifier PIR (Private Information Retrieval) into the Rust FFI layer, enabling Orchard note spendability detection without waiting for sequential shard-tree scanning. The wallet can now query a PIR server for nullifier presence and show correct spendable balances before the scan catches up. Rust FFI (spendability.rs): - zcashlc_check_wallet_spendability: queries unspent Orchard notes, checks each via PIR, records spent ones in pir_spent_notes with exponential-backoff retry for concurrent DB access. - zcashlc_get_pir_pending_spends: returns PIR-detected spends not yet confirmed by the block scanner. Swift wrappers: - SpendabilityBackend / SpendabilityTypes for FFI access. - Synchronizer protocol extensions: checkWalletSpendability, getPIRPendingSpends. - SDKFlags.pirCompleted lifecycle flag mirroring chainTipUpdated.
The monolithic zcashlc_check_wallet_spendability FFI call opened its own rusqlite::Connection, bypassing @DBActor serialization and creating a second concurrent writer that caused SQLITE_BUSY crashes during sync. Split into three separate FFI calls: 1. zcashlc_get_unspent_orchard_notes_for_pir — DB read via wallet_db() 2. zcashlc_check_nullifiers_pir — pure network, no DB handle 3. zcashlc_insert_pir_spent_notes — DB write via wallet_db() The DB-touching calls go through wallet_db() and @DBActor, matching the serialization pattern used by every other zcashlc_* function. The slow network call runs in a detached task holding no DB connection. Also adds a 5-second busyTimeout to SimpleConnectionProvider as defense-in-depth against any remaining contention between the Swift-side SQLite.swift connection and the Rust-side rusqlite connection. Includes unit tests for all FFI-boundary types (JSON serialization round-trips matching the Rust serde format) and the three-phase orchestration logic. Made-with: Cursor
Notes discovered during sync whose shards are not yet fully scanned cannot be spent because the wallet lacks a valid Merkle witness. This adds a PIR-based path to obtain witnesses from a server, bypassing the shard-scanned gate and making notes immediately spendable. Follows the same three-phase pattern as nullifier PIR to avoid SQLITE_BUSY: DB read (notes needing witnesses) runs through @DBActor, the PIR server fetch runs in a detached task with no DB connection, and the DB write (store witnesses) goes back through @DBActor. Rust FFI (witness.rs, lib.rs): - zcashlc_fetch_pir_witnesses — network-only, no DB handle - zcashlc_get_notes_needing_pir_witness — DB read via wallet_db() - zcashlc_insert_pir_witnesses — DB write via wallet_db() - zcashlc_get_pir_witnessed_notes — DB read via wallet_db() Swift wrappers: - WitnessBackend / WitnessTypes for FFI access - Synchronizer.fetchNoteWitnesses / getPIRWitnessedNotes Also deduplicates shared FFI helpers (str_from_ptr, json_to_boxed_slice) across spendability.rs and witness.rs, and removes redundant lastErrorMessage copies from SpendabilityBackend and WitnessBackend in favor of the existing module-level function. Made-with: Cursor
Use camelCase property name with CodingKeys mapping instead of snake_case field name for the FFI serialization struct. Made-with: Cursor
Point [patch.crates-io] to valargroup/librustzcash sync-pir-0.19.x (10541acf48) which carries both nullifier and witness PIR support. Update spendability-pir deps to valargroup/spendability-pir (bd5ef155) which includes witness-client. Rename feature flag from sync-nullifier-pir to spendability-pir to match the upstream rename. Add hex crate used for witness encoding. Made-with: Cursor
Proposal-selected Orchard notes can retain PIR witnesses from older anchors while sync is still moving. Record the witness server URL and refresh only the witnesses needed for the current proposal before retrying transaction construction once. Made-with: Cursor
Nullifier PIR should only remove spent Orchard notes from balance, not flip the whole pool into a ready state. Rely on the backend's note-specific witness state for spendability and remove the SDK-level pirCompleted projection. Made-with: Cursor
The PIR server now stores 41-byte entries with spend_height,
first_output_position, and action_count alongside each nullifier. Wire
this metadata through the FFI and Swift layers so downstream consumers
can use it for change-note tracking.
Rust FFI (spendability.rs): NullifierCheckResult.spent changes from
Vec<bool> to Vec<Option<SpendMetadata>>. Calls check_nullifiers()
which now returns metadata directly — the bool wrappers were removed
from spend-client.
Swift (SpendabilityTypes.swift): Add PIRSpendMetadata with spendHeight,
firstOutputPosition, actionCount. PIRNullifierCheckResult.spent changes
from [Bool] to [PIRSpendMetadata?].
SDKSynchronizer: spent-note filter updated from .filter { $0.1 } to
.filter { $0.1 != nil } to match the new optional type.
Tests updated for the new JSON shape (metadata objects / null instead
of true / false).
Made-with: Cursor
When nullifier PIR detects a note as spent, we need to find the immediate change note(s) in the spending transaction. This adds the Rust-side logic for that: given a CompactBlock and the spend metadata (position range from Phase 1), extract the relevant Orchard actions and trial-decrypt them against the wallet's viewing keys. The decryption core (try_decrypt_compact_actions) is factored as a standalone function that takes (position, CompactAction) pairs without caring where they came from. Today the pairs come from an RPC-fetched CompactBlock (extract_actions_from_block); a future decryption PIR refactor replaces only the data source while reusing the same decryption path. The FFI entry point (zcashlc_discover_change_notes) opens the wallet DB to retrieve the account's Orchard FVK, tries both internal and external IVK scopes, and returns discovered notes as JSON. The Swift wrappers and DB storage (provisional notes table) follow in Phase 3. Made-with: Cursor
The discover_change_notes FFI now inserts each decrypted note into
pir_provisional_notes and returns {position, value, provisional_note_id}
so the Swift orchestration layer can immediately request witnesses.
New parameters: spent_note_id (links back to the original note) and
spend_height (for reorg cleanup).
Adds zcashlc_mark_provisional_note_witnessed for flipping has_pir_witness
after a witness PIR succeeds, making the note appear in spendable balance.
Made-with: Cursor
Adds the Swift-side plumbing for change note discovery: - PIRDiscoveredNote type matching the FFI JSON response - discoverChangeNotes / markProvisionalNoteWitnessed on the RustBackend protocol and implementation - Orchestration in checkWalletSpendability: after detecting spent notes via nullifier PIR, download each spending block and trial-decrypt to find change outputs The FFI function now derives the account's Orchard FVK from the spent note ID internally, so Swift only needs to pass spent_note_id without tracking account UUIDs. Made-with: Cursor
Add cmx field assertions to decrypt_single_note and full_roundtrip tests (the commitment was never verified). Add external-scope decryption test and multi-hit test (3 decryptable actions in one call). Refactor make_encrypted_action into a parameterized helper to support both scopes. Made-with: Cursor
Expand # Safety sections on zcashlc_discover_change_notes and zcashlc_mark_provisional_note_witnessed to document the full db_data contract (alignment, path semantics, immutability, isize::MAX bound), matching the pattern used by all other FFI functions. Add doc comments to checkWalletSpendability and markProvisionalNoteWitnessed in Swift. Clarify that note_id refers to provisional_note_id, not a canonical orchard_received_notes ID. Remove one redundant inline comment. Made-with: Cursor
Depth-1 change discovery cannot resolve multi-hop spend chains where change notes are themselves spent in later transactions. The wallet needs to follow the full chain to determine its actual spendable balance. Extend the FFI layer with depth/parent tracking on discover_change_notes, a new get_provisional_notes_for_pir endpoint to retrieve unchecked provisional notes, and mark_provisional_pir_results to batch-update their PIR status. On the Swift side, restructure checkWalletSpendability into a two-phase loop: Phase 1 checks canonical notes (unchanged), Phase 2 iteratively PIR-checks provisional notes and discovers deeper change notes until the chain is fully resolved or maxDepth iterations are reached. Made-with: Cursor
The zcash_client_sqlite storage layer now uses a single pir_notes table, which changes two API surfaces that cross the FFI: - mark_provisional_note_witnessed now accepts witness data (siblings, anchor_height, anchor_root) directly, since witness columns live in the same row as the provisional note rather than a separate table. - insert_pir_provisional_note no longer takes spent_note_id; recursive chain linkage is handled entirely by parent_id and depth within pir_notes, so the caller doesn't need to pass the root note ID. Made-with: Cursor
fetchNoteWitnesses only handled canonical notes, leaving PIR-discovered provisional change notes without witnesses and perpetually pending. This adds a second phase that queries provisional notes needing witnesses, fetches them from the PIR server, and stores them via markProvisionalNoteWitnessed. Made-with: Cursor
Extract transaction metadata (txid, fee, block time) from the CompactBlock during change discovery and store it on the pir_notes row. This feeds a new zcashlc_get_pir_activity_entries FFI endpoint that returns net spend amounts (gross minus change) grouped by spending tx. On the Swift side, add PIRActivityEntry and thread it through the Synchronizer protocol so the iOS app can build standard "Sent" transaction rows from PIR data. These rows use the tx hash as their identity, so IdentifiedArray silently replaces them with scanner- confirmed entries as the scanner catches up — no visual glitch. Drop getPIRPendingSpends / PIRPendingSpends / PIRPendingNote and the underlying zcashlc_get_pir_pending_spends_v2 FFI — the activity entries subsume their UI role, and balance gating is handled independently by spent_notes_clause. Made-with: Cursor
Pick up 11 new commits: recursive change discovery in provisional notes, unified pir_notes table, provisional note witness queries, consolidated pir.rs module, and PIR activity entries with net spend amounts. Made-with: Cursor
Pick up extended nullifier entries (41-byte with spend metadata), witness window bootstrap fix, decryption-types crate, and updated documentation. Made-with: Cursor
Picks up the spendability-pir dev-dep bump (path → git at 3b33092a) so both repos reference the same spendability-pir revision. Made-with: Cursor
Blanket disable commands now use paired disable/enable to satisfy the blanket_disable_command rule across both Example app and Sources. Code fixes: rename short identifiers (hi/lo/c → high/low/char) in hex decoding helpers, remove superfluous string_concatenation disable, fix @DBActor attribute placement, remove redundant type annotations, fix opening brace spacing, add operator whitespace, remove unneeded viewDidLoad override, eliminate force-unwrap via optional map, and add targeted function_parameter_count / file_length suppression where splitting is impractical. Made-with: Cursor
XCTAssertNil wraps its argument in an @autoclosure which doesn't support async. Extract the await into a separate let binding. Made-with: Cursor
Transaction creation now proactively refreshes PIR witnesses for all Orchard notes in the proposal before building transactions, rather than relying solely on the post-failure retry. This eliminates unnecessary round-trips when the wallet is still syncing, since stale witnesses are refreshed upfront. The catch-and-retry path is kept as a safety net. A new insertMixedWitnesses helper separates canonical (positive noteId) and provisional (negative noteId) witness entries, routing them to the correct backend methods — insertPIRWitnesses for canonical notes and markProvisionalNoteWitnessed for provisional ones. Both the proactive alignment and the existing retry path use this helper. Made-with: Cursor
Refactors checkWalletSpendability to separate network I/O from DB mutations. Compact blocks are downloaded first and hex-encoded, then passed through applyPIRCanonicalRound / applyPIRProvisionalRound which delegate to the new atomic SAVEPOINT functions in librustzcash. This eliminates the per-note discoverChangeAtDepth calls that interleaved downloads with DB writes. Also adds resetPIRState for development/recovery, and fixes parent_pir_id resolution in the existing discover_change_notes FFI (parent_id was only set when a provisional parent was passed, not for canonical roots). 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.
sync-mainis made from themainupstream branch.Summary
Integrates nullifier PIR (Private Information Retrieval) into the Swift wallet SDK's Rust FFI layer, enabling Orchard note spendability detection without waiting for sequential shard-tree scanning.
Changes
Rust FFI (
rust/src/spendability.rs):zcashlc_check_wallet_spendability— queries unspent Orchard notes, checks each via PIR, records spent ones inpir_spent_noteswith exponential-backoff retry for concurrent DB access.zcashlc_get_pir_pending_spends— returns PIR-detected spends not yet confirmed by the block scanner.Swift wrappers:
SpendabilityBackend/SpendabilityTypes— FFI access layer.Synchronizer.checkWalletSpendability/getPIRPendingSpends— public API.SDKFlags.pirCompletedlifecycle flag mirroringchainTipUpdated.CHANGELOGs: both
CHANGELOG.mdandrust/CHANGELOG.mdupdated under Unreleased.Dependencies
All
[patch.crates-io]entries pin to thesync-pir-0.19.xbranch of valargroup/librustzcash (9861871), which carries PIR spent-note tracking on top of the 0.19.x release line.spend-client/spend-typespinned to valargroup/sync-nullifier-pir main (ab81f98).All Relevant PRs
librustzcash(0.19.x backport): zcash#2268zcash-swift-wallet-sdk: valargroup#12Notes
GitHub Issues are disabled on this fork, so the CONTRIBUTING.md requirement for an associated issue is N/A. The upstream
[#{issue_number}]commit title format is intentionally not used for feature branches on this fork.