Skip to content

Detect spent orchard notes via PIR#1675

Closed
p0mvn wants to merge 3 commits into
zodl-inc:mainfrom
valargroup:sync-pir
Closed

Detect spent orchard notes via PIR#1675
p0mvn wants to merge 3 commits into
zodl-inc:mainfrom
valargroup:sync-pir

Conversation

@p0mvn
Copy link
Copy Markdown
Collaborator

@p0mvn p0mvn commented Apr 4, 2026

Summary

Integrate the nullifier PIR (Private Information Retrieval) service so the app can detect spent orchard notes before the block scanner catches up. Without PIR, a spent note's balance appears available until full shard-tree scanning confirms the spend — which can take a long time and would lead to broadcast failures if the user tries to spend the stale balance.

How it works

  1. Startup: After the SDK synchronizer registers for updates (registerForSynchronizersUpdate), the app fires checkSpendabilityPIR which calls the PIR server via sdkSynchronizer.checkWalletSpendability(pirUrl). The server checks each unspent orchard note's nullifier against a compact nullifier database.
  2. During sync: Whenever foundTransactions (new transactions discovered mid-sync) or syncReachedUpToDate (sync completed) fires, checkSpendabilityPIR is triggered again — alongside the normal transaction refresh.
  3. Deduplication: The PIR effect uses .cancellable(id: PIRCheckCancelId, cancelInFlight: true) so rapid events during a long sync coalesce into a single server request.
  4. Result handling: checkSpendabilityPIRResult stores the result in @Shared(.pirSpendabilityResult) and triggers both a transaction list refresh and a balance update.
  5. Detected-spend placeholder: When fetchedTransactions processes the transaction list, it also calls getPIRPendingSpends() which queries the local pir_spent_notes SQLite table for notes not yet confirmed by the scanner. If any exist, a synthetic "Detected spend" TransactionState row is appended to the activity feed. This placeholder auto-reconciles — once the scanner catches up and the note appears in orchard_received_note_spends, getPIRPendingSpends returns empty and the placeholder disappears.

Feature flag

All PIR code paths are gated behind WalletConfig.isEnabled(.pirSpendability):

  • registerForSynchronizersUpdate — only sends checkSpendabilityPIR if flag is on
  • checkSpendabilityPIR — early-returns .none if flag is off
  • foundTransactions / syncReachedUpToDate — only triggers PIR check if flag is on
  • fetchTransactionsForTheSelectedAccount — only calls getPIRPendingSpends() if flag is on
  • fetchedTransactions — only appends the PIR placeholder if flag is on

The flag is defined in WalletConfig (not FeatureFlags) as the single source of truth, togglable via WalletConfigProvider / debug flows.

Demo

Detect Spend During Sync

https://www.youtube.com/watch?v=TMFQUpdcnLo

Once the spend is detected, the balance decreases by the full note amount. The decreased balance remains spendable — enabled by the sync nullifier PIR — until the wallet scans the spend and discovers the output change note.

Comparison to No PIR via Debug Settings

https://www.youtube.com/watch?v=JOKyPA1pqwI

Without PIR, the full balance appears available even though it is partially spent, which would lead to a broadcast failure. Triggering a PIR check via the debug menu shows the balance dropping to the correct spendable amount.

Screenshot

image

Related PRs

Test plan

  1. Have a wallet with at least one orchard note that has been spent
  2. Launch the app — PIR runs at startup and the balance should reflect the spend before the scanner catches up
  3. Observe "Detected spend" row in the activity feed
  4. Wait for the scanner to catch up — the placeholder should disappear and be replaced by the real transaction
  5. Force-close and reopen the app — PIR should re-trigger and the placeholder should reappear if scanning hasn't caught up yet
  6. Disable pirSpendability flag — verify no PIR requests are made and no placeholder appears

Checklist

  • Self-review in GitHub web interface
  • Automated tests added (PIRSpendabilityTests.swift — 10 tests covering success/failure, flag gating, placeholder insertion)
  • CHANGELOG updated
  • App run and changes verified manually
  • Demo videos provided

p0mvn added 3 commits April 3, 2026 23:52
Wire the spendability PIR client from the SDK fork into the app so
orchard note spendability is detected via PIR rather than waiting for
full shard-tree scanning. PIR runs at startup and again whenever
foundTransactions or syncReachedUpToDate fires, with cancelInFlight
to coalesce rapid events. Detected-spend placeholder appears in the
transaction list until scanning catches up.
All PIR code paths (startup check, sync-triggered check, pending-spend
placeholder, getPIRPendingSpends call) are now gated behind the
pirSpendability WalletConfig flag, disabled by default.

PIR "Detected spend" rows are non-tappable — the button is disabled
and the coordinator ignores taps on PIR placeholder transactions.

Made-with: Cursor
The synthetic "Detected Spend" row had three UX issues:

- It appeared at the bottom of the activity list because it had no
  timestamp, falling through to an unstable height-based sort. Give it
  the current time so it sorts to the top where users expect to see
  recent activity.

- Tapping the row navigated to an empty transaction detail screen.
  Guard the tap handler to return .none for PIR placeholders.

- The loading spinner was too close to the amount text. Add trailing
  padding to separate them visually.

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.

1 participant