Skip to content

zcash-swift-wallet-sdk: Nullifier PIR integration#12

Closed
p0mvn wants to merge 27 commits into
sync-mainfrom
sync-pir
Closed

zcash-swift-wallet-sdk: Nullifier PIR integration#12
p0mvn wants to merge 27 commits into
sync-mainfrom
sync-pir

Conversation

@p0mvn

@p0mvn p0mvn commented Apr 3, 2026

Copy link
Copy Markdown

sync-main is made from the main upstream 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 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 — FFI access layer.
  • Synchronizer.checkWalletSpendability / getPIRPendingSpends — public API.
  • SDKFlags.pirCompleted lifecycle flag mirroring chainTipUpdated.

CHANGELOGs: both CHANGELOG.md and rust/CHANGELOG.md updated under Unreleased.

Dependencies

All [patch.crates-io] entries pin to the sync-pir-0.19.x branch of valargroup/librustzcash (9861871), which carries PIR spent-note tracking on top of the 0.19.x release line.

spend-client / spend-types pinned to valargroup/sync-nullifier-pir main (ab81f98).

All Relevant PRs

Notes

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.

LukasKorba and others added 7 commits April 4, 2026 12:56
- Checkpoints updated
- Changelog updated
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
p0mvn added 13 commits April 5, 2026 23:38
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
p0mvn added 7 commits April 7, 2026 21:04
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
@p0mvn p0mvn closed this Apr 10, 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