Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ and this library adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

# Unreleased

## Added
- PIR (Private Information Retrieval) integration for detecting Orchard note spendability without waiting for shard-tree scanning to complete.
- `SpendabilityBackend` / `SpendabilityTypes` — Swift wrappers for the nullifier and witness PIR FFI layer.
- `Synchronizer.checkWalletSpendability` — queries a PIR server to determine which notes have been spent, without revealing the wallet's notes to the server.
- `Synchronizer.fetchNoteWitnesses` — fetches Orchard note commitment witnesses from a PIR server, making notes spendable before the scanner catches up.
- `SDKFlags.pirCompleted` — lifecycle flag preserving spendable balance across sync restarts.
- `Proposal.PIRWitnessConfig` — attach to a `Proposal` via `proposal.pirWitnessConfig` to enable PIR witness fetching when the wallet is not fully synced. The SDK handles alignment and retry logic automatically.
- `createProposedTransactions` reads PIR configuration from the proposal itself, keeping the method signature clean.

# 2.4.9 - 2026-04-04

## Checkpoints
Expand Down
45 changes: 0 additions & 45 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions Sources/ZcashLightClientKit/ClosureSynchronizer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,13 @@ public protocol ClosureSynchronizer {
/// proposal, indicating whether they were submitted to the network or if an error
/// occurred.
///
/// - Parameters:
/// - proposal: The proposal for which to create transactions. Attach a
/// `Proposal.PIRWitnessConfig` via `proposal.pirWitnessConfig` to enable PIR witness
/// fetching when the wallet is not fully synced.
/// - spendingKey: The `UnifiedSpendingKey` for the account that controls the funds.
/// - completion: Completion handler.
///
/// If `prepare()` hasn't already been called since creation of the synchronizer instance
/// or since the last wipe then this method throws `SynchronizerErrors.notPrepared`.
func createProposedTransactions(
Expand Down
6 changes: 6 additions & 0 deletions Sources/ZcashLightClientKit/CombineSynchronizer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,12 @@ public protocol CombineSynchronizer {
///
/// If `prepare()` hasn't already been called since creation of the synchronizer instance
/// or since the last wipe then this method throws `SynchronizerErrors.notPrepared`.
///
/// - Parameters:
/// - proposal: The proposal for which to create transactions. Attach a
/// `Proposal.PIRWitnessConfig` via `proposal.pirWitnessConfig` to enable PIR witness
/// fetching when the wallet is not fully synced.
/// - spendingKey: The `UnifiedSpendingKey` for the account that controls the funds.
func createProposedTransactions(
proposal: Proposal,
spendingKey: UnifiedSpendingKey
Expand Down
21 changes: 21 additions & 0 deletions Sources/ZcashLightClientKit/Model/Proposal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,29 @@ import Foundation

/// A data structure that describes a series of transactions to be created.
public struct Proposal: Equatable {
/// PIR witness configuration attached to a proposal.
///
/// Set `serverURL` before calling `createProposedTransactions` so the SDK
/// can fetch Orchard witnesses from the PIR server when the wallet is not
/// fully synced. The SDK sets `usePIRWitnesses` internally based on sync
/// status and retry logic.
public struct PIRWitnessConfig: Equatable, Sendable {
public let serverURL: String
public internal(set) var usePIRWitnesses: Bool

public init(serverURL: String) {
self.serverURL = serverURL
self.usePIRWitnesses = false
}
}

let inner: FfiProposal

/// Optional PIR witness configuration. When set, the SDK will use the
/// provided server URL to fetch Orchard witnesses if the wallet is not
/// fully synced, enabling spending before scanning completes.
public var pirWitnessConfig: PIRWitnessConfig?

/// Returns the number of transactions that this proposal will create.
///
/// This is equal to the number of `TransactionSubmitResult`s that will be returned
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// Swift wrapper for the PIR C FFI (spendability.rs + witness.rs).
// Stateless — each call connects to the PIR server and returns.

import Foundation
import libzcashlc

// MARK: - Error

public enum SpendabilityBackendError: LocalizedError, Equatable {
case rustError(String)

public var errorDescription: String? {
switch self {
case .rustError(let message):
return "Spendability backend error: \(message)"
}
}
}

// MARK: - SpendabilityBackend

/// Wraps the PIR network FFI. Stateless — no DB handle, no persistent connection.
public struct SpendabilityBackend: Sendable {
public init() {}

/// Check nullifiers against the PIR server. No database access.
///
/// - Parameters:
/// - notes: Unspent notes with nullifiers (from phase 1 DB read).
/// - pirServerUrl: Base URL of the spend-server.
/// - progress: Optional progress callback (0.0..1.0).
/// - Returns: A `PIRNullifierCheckResult` with spent flags and server metadata.
public func checkNullifiersPIR(
notes: [PIRUnspentNote],
pirServerUrl: String,
progress: SpendabilityProgressHandler?
) throws -> PIRNullifierCheckResult {
let urlBytes = [UInt8](pirServerUrl.utf8)

let nullifiers: [[UInt8]] = notes.map { $0.nf }
let nullifiersJSON = try JSONEncoder().encode(nullifiers)

var context = SpendabilityProgressContext(handler: progress)

let ptr: UnsafeMutablePointer<FfiBoxedSlice>? = urlBytes.withUnsafeBufferPointer { urlBuf in
nullifiersJSON.withUnsafeBytes { nfBuf in
withUnsafeMutablePointer(to: &context) { ctxPtr in
let callback: (@convention(c) (Double, UnsafeMutableRawPointer?) -> Void)? =
progress != nil ? spendabilityProgressTrampoline : nil
return zcashlc_check_nullifiers_pir(
urlBuf.baseAddress,
UInt(urlBuf.count),
nfBuf.baseAddress?.assumingMemoryBound(to: UInt8.self),
UInt(nfBuf.count),
callback,
UnsafeMutableRawPointer(ctxPtr)
)
}
}
}

guard let ptr else {
throw SpendabilityBackendError.rustError(lastErrorMessage(fallback: "`checkNullifiersPIR` failed"))
}
defer { zcashlc_free_boxed_slice(ptr) }

let data = Data(bytes: ptr.pointee.ptr, count: Int(ptr.pointee.len))
return try JSONDecoder().decode(PIRNullifierCheckResult.self, from: data)
}

/// Fetch note commitment witnesses from the PIR server. No database access.
///
/// - Parameters:
/// - notes: Notes needing witnesses (from DB read).
/// - pirServerUrl: Base URL of the witness PIR server.
/// - progress: Optional progress callback (0.0..1.0).
/// - Returns: A `PIRWitnessResult` with witness data for each note.
public func fetchWitnesses(
notes: [PIRNotePosition],
pirServerUrl: String,
progress: SpendabilityProgressHandler?
) throws -> PIRWitnessResult {
let urlBytes = [UInt8](pirServerUrl.utf8)

struct PositionInput: Codable {
let note_id: Int64

Check failure on line 86 in Sources/ZcashLightClientKit/Rust/Spendability/SpendabilityBackend.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Identifier Name Violation: Variable name 'note_id' should only contain alphanumeric and other allowed characters (identifier_name)
let position: UInt64
}

let positions = notes.map { PositionInput(note_id: $0.id, position: $0.position) }
let positionsJSON = try JSONEncoder().encode(positions)

var context = SpendabilityProgressContext(handler: progress)

let ptr: UnsafeMutablePointer<FfiBoxedSlice>? = urlBytes.withUnsafeBufferPointer { urlBuf in
positionsJSON.withUnsafeBytes { posBuf in
withUnsafeMutablePointer(to: &context) { ctxPtr in
let callback: (@convention(c) (Double, UnsafeMutableRawPointer?) -> Void)? =
progress != nil ? spendabilityProgressTrampoline : nil
return zcashlc_fetch_pir_witnesses(
urlBuf.baseAddress,
UInt(urlBuf.count),
posBuf.baseAddress?.assumingMemoryBound(to: UInt8.self),
UInt(posBuf.count),
callback,
UnsafeMutableRawPointer(ctxPtr)
)
}
}
}

guard let ptr else {
throw SpendabilityBackendError.rustError(lastErrorMessage(fallback: "`fetchWitnesses` failed"))
}
defer { zcashlc_free_boxed_slice(ptr) }

let data = Data(bytes: ptr.pointee.ptr, count: Int(ptr.pointee.len))
return try JSONDecoder().decode(PIRWitnessResult.self, from: data)
}
}

// MARK: - Progress callback trampoline

private struct SpendabilityProgressContext {
let handler: SpendabilityProgressHandler?
}

private func spendabilityProgressTrampoline(progress: Double, context: UnsafeMutableRawPointer?) {
guard let context else { return }
let ctx = context.assumingMemoryBound(to: SpendabilityProgressContext.self).pointee
ctx.handler?(progress)
}
Loading
Loading