Skip to main content

darkpool_client/
privacy_client.rs

1//! Unified client interface for the `DarkPool` protocol.
2//! Combines key management, UTXO tracking, proof generation, and transport into a single API.
3
4use ethers::prelude::*;
5#[cfg(feature = "mixnet")]
6use ethers::types::transaction::eip2718::TypedTransaction;
7use ethers::types::{Address, U256};
8use std::collections::HashSet;
9use std::sync::Arc;
10use std::time::Duration;
11use thiserror::Error;
12use tracing::{debug, info, warn};
13
14use crate::builder::TransactionBuilder;
15#[cfg(feature = "mixnet")]
16use crate::economics::PriceData;
17use crate::identity::DarkAccount;
18use crate::key_repository::KeyRepository;
19use crate::merkle_tree::LocalMerkleTree;
20use crate::note_factory::{ChangeNoteResult, NoteFactory, SpendingInputs};
21#[cfg(feature = "mixnet")]
22use crate::note_processor::WalletNote;
23use crate::prover::ClientProver;
24use crate::scan_engine::{ScanEngine, ScanResult};
25use crate::utxo_store::{OwnedNote, UtxoStore};
26use nox_core::traits::interfaces::IProverService;
27
28#[cfg(feature = "mixnet")]
29use nox_client::mixnet_client::{MixnetClient, MixnetClientError};
30
31/// Privacy client errors
32#[derive(Debug, Error)]
33pub enum PrivacyClientError {
34    #[error("Insufficient balance: need {needed}, have {have}")]
35    InsufficientBalance { needed: U256, have: U256 },
36    #[error("No spendable notes found")]
37    NoSpendableNotes,
38    #[error("Note selection failed: {0}")]
39    NoteSelectionFailed(String),
40    #[error("Proof generation failed: {0}")]
41    ProofFailed(String),
42    #[error("Transaction failed: {0}")]
43    TransactionFailed(String),
44    #[error("Scan error: {0}")]
45    ScanError(String),
46    #[error("Provider error: {0}")]
47    ProviderError(String),
48    #[error("Tree sync mismatch: local root {local:?} != on-chain root {onchain:?}")]
49    TreeMismatch { local: U256, onchain: U256 },
50    #[error("Mixnet error: {0}")]
51    MixnetError(String),
52    #[error("Invalid memo: {0}")]
53    InvalidMemo(String),
54    #[error("Cryptographic operation failed: {0}")]
55    CryptoFailed(String),
56    #[error("Configuration error: {0}")]
57    Config(String),
58    #[error("Gas fee {fee} exceeds payment note value {note_value}")]
59    GasFeeExceedsNoteValue { fee: U256, note_value: U256 },
60    #[error("Persistence error: {0}")]
61    Persistence(#[from] crate::persistence::PersistenceError),
62}
63
64#[cfg(feature = "mixnet")]
65impl From<MixnetClientError> for PrivacyClientError {
66    fn from(e: MixnetClientError) -> Self {
67        PrivacyClientError::MixnetError(e.to_string())
68    }
69}
70
71pub type PrivacyClientConfig = crate::config::DarkPoolConfig;
72pub use crate::config::PrivacyTxResult;
73
74/// Determines how a transaction is submitted to the blockchain.
75pub enum Transport<'a> {
76    Direct,
77    /// Gas payment ZK proof + mixnet submission. Change notes are discoverable
78    /// via pre-registered ephemeral keys.
79    #[cfg(feature = "mixnet")]
80    PaidMixnet {
81        client: &'a MixnetClient,
82        payment_asset: Address,
83        prices: &'a PriceData,
84        relayer_address: Address,
85    },
86    /// User signs TX locally; exit node calls `eth_sendRawTransaction`.
87    /// IP privacy via Sphinx routing, no gas notes needed.
88    #[cfg(feature = "mixnet")]
89    SignedBroadcast {
90        client: &'a MixnetClient,
91    },
92    /// Phantom variant to avoid unused lifetime warning when mixnet feature is disabled.
93    #[cfg(not(feature = "mixnet"))]
94    #[doc(hidden)]
95    _Phantom(std::marker::PhantomData<&'a ()>),
96}
97
98struct SubmitResult {
99    tx_hash: H256,
100    block_num: u64,
101    gas_used: U256,
102    payment_nullifier: Option<U256>,
103    /// Used for local state sync; `eth_getLogs` returns 0 on Anvil for recently mined blocks.
104    receipt_logs: Vec<ethers::types::Log>,
105}
106
107/// Main entry point for `DarkPool` operations. Queries can optionally route through the Mixnet.
108pub struct PrivacyClient<M: Middleware + Clone + 'static> {
109    signer: Arc<SignerMiddleware<M, LocalWallet>>,
110    keys: KeyRepository,
111    utxos: UtxoStore,
112    tree: LocalMerkleTree,
113    builder: TransactionBuilder,
114    note_factory: NoteFactory,
115    config: PrivacyClientConfig,
116    last_synced_block: u64,
117}
118
119impl<M: Middleware + Clone + 'static> PrivacyClient<M> {
120    pub async fn new(
121        provider: Arc<M>,
122        wallet: LocalWallet,
123        dark_account: DarkAccount,
124        config: PrivacyClientConfig,
125        prover: Arc<dyn IProverService>,
126    ) -> Result<Self, PrivacyClientError> {
127        let timeout_ms = config.provider_timeout_ms;
128        let chain_id =
129            tokio::time::timeout(Duration::from_millis(timeout_ms), provider.get_chainid())
130                .await
131                .map_err(|_| {
132                    PrivacyClientError::ProviderError(format!(
133                        "get_chainid timed out after {timeout_ms}ms"
134                    ))
135                })?
136                .map_err(|e| PrivacyClientError::ProviderError(format!("get_chainid: {e}")))?
137                .as_u64();
138
139        let wallet_with_chain = wallet.with_chain_id(chain_id);
140        let signer = Arc::new(SignerMiddleware::new(
141            (*provider).clone(),
142            wallet_with_chain,
143        ));
144
145        if config.darkpool_address == Address::zero() {
146            return Err(PrivacyClientError::Config(
147                "darkpool_address is zero -- all transactions would be sent to the zero address"
148                    .into(),
149            ));
150        }
151
152        let mut keys = KeyRepository::new(dark_account, config.compliance_pk);
153        // Pre-register a default lookahead window of incoming keys so that
154        // the scan engine can detect transfers on the very first sync.
155        // Without this, the recipient_key_map is empty and no transfer memos
156        // can be matched until notes are found (chicken-and-egg problem).
157        keys.advance_incoming_keys(crate::key_repository::DEFAULT_LOOKAHEAD);
158        let client_prover = Arc::new(ClientProver::with_service(prover));
159
160        let mut builder_config = config.builder_config.clone();
161        builder_config.compliance_pk = config.compliance_pk;
162        builder_config.darkpool_address = config.darkpool_address;
163        let builder = TransactionBuilder::new(client_prover, builder_config);
164
165        let note_factory = NoteFactory::new(config.compliance_pk);
166
167        info!(
168            "Privacy Client initialized. DarkPool: {:?}",
169            config.darkpool_address
170        );
171
172        Ok(Self {
173            signer,
174            keys,
175            utxos: UtxoStore::new(),
176            tree: LocalMerkleTree::new(),
177            builder,
178            note_factory,
179            config,
180            last_synced_block: 0,
181        })
182    }
183
184    /// Alias for [`new()`](Self::new).
185    pub async fn with_prover(
186        provider: Arc<M>,
187        wallet: LocalWallet,
188        dark_account: DarkAccount,
189        config: PrivacyClientConfig,
190        prover: Arc<dyn IProverService>,
191    ) -> Result<Self, PrivacyClientError> {
192        Self::new(provider, wallet, dark_account, config, prover).await
193    }
194
195    #[must_use]
196    pub fn balance(&self, asset: Address) -> U256 {
197        self.utxos.get_balance(asset)
198    }
199
200    #[must_use]
201    pub fn merkle_root(&self) -> U256 {
202        self.tree.root()
203    }
204
205    #[must_use]
206    pub fn note_count(&self) -> usize {
207        self.utxos.count()
208    }
209
210    pub fn receiving_key(&mut self) -> Result<(U256, U256), PrivacyClientError> {
211        self.keys
212            .get_public_incoming_key()
213            .map_err(|e| PrivacyClientError::CryptoFailed(e.to_string()))
214    }
215
216    pub fn advance_keys(&mut self, count: u64) {
217        self.keys.advance_ephemeral_keys(count);
218        self.keys.advance_incoming_keys(count);
219    }
220
221    /// Atomic write (temp file + rename). Pending spends are not persisted.
222    pub fn save_state(&self, path: &std::path::Path) -> Result<(), PrivacyClientError> {
223        crate::persistence::save_wallet_state(path, &self.utxos, &self.tree, self.last_synced_block)
224            .map_err(PrivacyClientError::Persistence)
225    }
226
227    /// Returns `Ok(true)` if loaded, `Ok(false)` if file doesn't exist (fresh wallet).
228    pub fn load_state(&mut self, path: &std::path::Path) -> Result<bool, PrivacyClientError> {
229        match crate::persistence::load_wallet_state(path)? {
230            Some((utxos, tree, block)) => {
231                self.utxos = utxos;
232                self.tree = tree;
233                self.last_synced_block = block;
234                Ok(true)
235            }
236            None => Ok(false),
237        }
238    }
239
240    /// Requires prior ERC-20 approval for the `DarkPool` contract.
241    pub async fn deposit(
242        &mut self,
243        amount: U256,
244        asset: Address,
245    ) -> Result<PrivacyTxResult, PrivacyClientError> {
246        info!("Depositing {} of {:?}", amount, asset);
247
248        let deposit_result = self
249            .note_factory
250            .create_deposit_note(amount, asset, &mut self.keys)
251            .map_err(|e| PrivacyClientError::ProofFailed(e.to_string()))?;
252
253        let proof_bundle = self
254            .builder
255            .build_deposit(&deposit_result)
256            .await
257            .map_err(|e| PrivacyClientError::ProofFailed(e.to_string()))?;
258
259        debug!(
260            "Deposit proof generated. Commitment: {:?}",
261            deposit_result.commitment
262        );
263
264        let tx = TransactionRequest::new()
265            .to(self.config.darkpool_address)
266            .data(proof_bundle.calldata.clone());
267
268        let pending = self
269            .timed_provider_call(
270                "deposit send_transaction",
271                self.signer.send_transaction(tx, None),
272            )
273            .await?;
274
275        let receipt = self
276            .timed_provider_call("deposit pending confirmation", pending)
277            .await?
278            .ok_or_else(|| {
279                PrivacyClientError::TransactionFailed("No receipt received".to_string())
280            })?;
281
282        info!(
283            "Deposit successful. TxHash: {:?}, Commitment: {:?}",
284            receipt.transaction_hash, deposit_result.commitment
285        );
286
287        let leaf_index = self.tree.insert(deposit_result.commitment);
288
289        let shared_secret = crate::crypto_helpers::derive_shared_secret_bjj(
290            deposit_result.ephemeral_sk,
291            self.config.compliance_pk,
292        )
293        .map_err(|e| PrivacyClientError::CryptoFailed(format!("ECDH failed: {e}")))?;
294
295        let block_num = if let Some(b) = receipt.block_number {
296            b.as_u64()
297        } else {
298            warn!(
299                "Receipt missing block_number for tx {:?}, using last_synced_block={}",
300                receipt.transaction_hash, self.last_synced_block
301            );
302            self.last_synced_block
303        };
304
305        self.utxos.add_note(
306            OwnedNote {
307                plaintext: deposit_result.note.clone(),
308                commitment: deposit_result.commitment,
309                leaf_index,
310                spending_secret: shared_secret,
311                is_transfer: false,
312                received_block: block_num,
313            },
314            crate::crypto_helpers::derive_nullifier_path_a(deposit_result.note.nullifier),
315        );
316
317        self.last_synced_block = block_num;
318
319        Ok(PrivacyTxResult {
320            tx_hash: receipt.transaction_hash,
321            new_commitments: vec![deposit_result.commitment],
322            spent_nullifiers: vec![],
323            gas_used: receipt.gas_used.unwrap_or_else(|| {
324                warn!(
325                    "Receipt missing gas_used for tx {:?}",
326                    receipt.transaction_hash
327                );
328                U256::zero()
329            }),
330        })
331    }
332
333    pub async fn withdraw_with_transport(
334        &mut self,
335        transport: &Transport<'_>,
336        amount: U256,
337        asset: Address,
338        recipient: Address,
339        intent_hash: Option<U256>,
340    ) -> Result<PrivacyTxResult, PrivacyClientError> {
341        info!("Withdrawing {} of {:?} to {:?}", amount, asset, recipient);
342
343        let balance = self.balance(asset);
344        if balance < amount {
345            return Err(PrivacyClientError::InsufficientBalance {
346                needed: amount,
347                have: balance,
348            });
349        }
350
351        let selected = self
352            .utxos
353            .select_notes(asset, amount)
354            .ok_or(PrivacyClientError::NoSpendableNotes)?;
355        let note = (*selected
356            .first()
357            .ok_or(PrivacyClientError::NoSpendableNotes)?)
358        .clone();
359        let excluded: HashSet<U256> = selected.iter().map(|n| n.commitment).collect();
360        drop(selected);
361
362        let spending_inputs = self.create_spending_inputs(&note)?;
363        let nullifier_hash = Self::derive_nullifier_hash(&note);
364
365        for c in &excluded {
366            self.utxos.mark_pending_spend(c);
367        }
368
369        let change_value = note.plaintext.value.saturating_sub(amount);
370        let change_result = if change_value.is_zero() {
371            None
372        } else {
373            Some(
374                self.note_factory
375                    .create_change_note(change_value, asset, &mut self.keys)
376                    .map_err(|e| PrivacyClientError::ProofFailed(e.to_string()))?,
377            )
378        };
379
380        self.ensure_synced_with_transport(transport).await?;
381
382        let merkle_root = self.tree.root();
383        let proof_bundle = self
384            .builder
385            .build_withdraw(
386                &spending_inputs,
387                amount,
388                recipient,
389                merkle_root,
390                change_result.as_ref(),
391                intent_hash,
392                0,
393            )
394            .await
395            .map_err(|e| PrivacyClientError::ProofFailed(e.to_string()))?;
396
397        debug!("Withdraw proof generated for {} to {:?}", amount, recipient);
398
399        let submit = self
400            .submit_and_confirm(
401                transport,
402                proof_bundle.calldata.clone(),
403                merkle_root,
404                &excluded,
405                U256::from(self.config.gas_limits.withdraw),
406            )
407            .await?;
408
409        let mut new_commitments = vec![];
410        if let Some(ref change) = change_result {
411            new_commitments.push(change.commitment);
412        }
413
414        if submit.payment_nullifier.is_some() {
415            // Gas payment change note is inserted on-chain BEFORE action output notes
416            self.sync_from_receipt_logs(&submit.receipt_logs, submit.block_num)?;
417        } else {
418            if let Some(ref change) = change_result {
419                self.register_self_note(change, submit.block_num)?;
420            }
421            self.last_synced_block = submit.block_num;
422        }
423
424        self.utxos.mark_spent(nullifier_hash);
425        let mut spent_nullifiers = vec![nullifier_hash];
426        if let Some(pn) = submit.payment_nullifier {
427            self.utxos.mark_spent(pn);
428            spent_nullifiers.push(pn);
429        }
430
431        for c in &excluded {
432            self.utxos.clear_pending_spend(c);
433        }
434
435        Ok(PrivacyTxResult {
436            tx_hash: submit.tx_hash,
437            new_commitments,
438            spent_nullifiers,
439            gas_used: submit.gas_used,
440        })
441    }
442
443    pub async fn split_with_transport(
444        &mut self,
445        transport: &Transport<'_>,
446        amount_a: U256,
447        amount_b: U256,
448        asset: Address,
449    ) -> Result<PrivacyTxResult, PrivacyClientError> {
450        let total = amount_a + amount_b;
451        info!("Splitting note: {} + {} of {:?}", amount_a, amount_b, asset);
452
453        // Split circuit enforces strict conservation: input == output_a + output_b
454        let note = self
455            .utxos
456            .select_note_exact(asset, total)
457            .ok_or(PrivacyClientError::NoSpendableNotes)?
458            .clone();
459        let excluded: HashSet<U256> = [note.commitment].into_iter().collect();
460
461        let spending_inputs = self.create_spending_inputs(&note)?;
462        let nullifier_hash = Self::derive_nullifier_hash(&note);
463
464        for c in &excluded {
465            self.utxos.mark_pending_spend(c);
466        }
467
468        let (note_a, note_b) = self
469            .note_factory
470            .create_split_notes(amount_a, amount_b, asset, &mut self.keys)
471            .map_err(|e| PrivacyClientError::ProofFailed(e.to_string()))?;
472
473        self.ensure_synced_with_transport(transport).await?;
474
475        let merkle_root = self.tree.root();
476        let proof_bundle = self
477            .builder
478            .build_split(&spending_inputs, merkle_root, &note_a, &note_b, 0)
479            .await
480            .map_err(|e| PrivacyClientError::ProofFailed(e.to_string()))?;
481
482        debug!(
483            "Split proof generated. Outputs: {:?}, {:?}",
484            note_a.commitment, note_b.commitment
485        );
486
487        let submit = self
488            .submit_and_confirm(
489                transport,
490                proof_bundle.calldata.clone(),
491                merkle_root,
492                &excluded,
493                U256::from(self.config.gas_limits.split),
494            )
495            .await?;
496
497        if submit.payment_nullifier.is_some() {
498            // Gas payment change note is inserted on-chain BEFORE action output notes
499            self.sync_from_receipt_logs(&submit.receipt_logs, submit.block_num)?;
500        } else {
501            self.register_self_note(&note_a, submit.block_num)?;
502            self.register_self_note(&note_b, submit.block_num)?;
503            self.last_synced_block = submit.block_num;
504        }
505
506        self.utxos.mark_spent(nullifier_hash);
507        let mut spent_nullifiers = vec![nullifier_hash];
508        if let Some(pn) = submit.payment_nullifier {
509            self.utxos.mark_spent(pn);
510            spent_nullifiers.push(pn);
511        }
512
513        for c in &excluded {
514            self.utxos.clear_pending_spend(c);
515        }
516
517        Ok(PrivacyTxResult {
518            tx_hash: submit.tx_hash,
519            new_commitments: vec![note_a.commitment, note_b.commitment],
520            spent_nullifiers,
521            gas_used: submit.gas_used,
522        })
523    }
524
525    pub async fn join_with_transport(
526        &mut self,
527        transport: &Transport<'_>,
528        asset: Address,
529    ) -> Result<PrivacyTxResult, PrivacyClientError> {
530        info!("Joining notes for {:?}", asset);
531
532        let unspent: Vec<_> = self
533            .utxos
534            .get_unspent()
535            .into_iter()
536            .filter(|n| n.plaintext.asset_id == crate::crypto_helpers::address_to_field(asset))
537            .cloned()
538            .collect();
539        if unspent.len() < 2 {
540            return Err(PrivacyClientError::NoteSelectionFailed(
541                "Need at least 2 notes to join".to_string(),
542            ));
543        }
544
545        let note_a = &unspent[0];
546        let note_b = &unspent[1];
547
548        let excluded: HashSet<U256> = [note_a.commitment, note_b.commitment].into_iter().collect();
549
550        let spending_inputs_a = self.create_spending_inputs(note_a)?;
551        let spending_inputs_b = self.create_spending_inputs(note_b)?;
552        let nullifier_a = Self::derive_nullifier_hash(note_a);
553        let nullifier_b = Self::derive_nullifier_hash(note_b);
554
555        for c in &excluded {
556            self.utxos.mark_pending_spend(c);
557        }
558
559        let total_value = note_a.plaintext.value + note_b.plaintext.value;
560
561        let output = self
562            .note_factory
563            .create_join_output_note(total_value, asset, &mut self.keys)
564            .map_err(|e| PrivacyClientError::ProofFailed(e.to_string()))?;
565
566        self.ensure_synced_with_transport(transport).await?;
567
568        let merkle_root = self.tree.root();
569        let proof_bundle = self
570            .builder
571            .build_join(
572                &spending_inputs_a,
573                &spending_inputs_b,
574                merkle_root,
575                &output,
576                0,
577            )
578            .await
579            .map_err(|e| PrivacyClientError::ProofFailed(e.to_string()))?;
580
581        debug!("Join proof generated. Output: {:?}", output.commitment);
582
583        let submit = self
584            .submit_and_confirm(
585                transport,
586                proof_bundle.calldata.clone(),
587                merkle_root,
588                &excluded,
589                U256::from(self.config.gas_limits.join),
590            )
591            .await?;
592
593        if submit.payment_nullifier.is_some() {
594            // Gas payment change note is inserted on-chain BEFORE action output notes
595            self.sync_from_receipt_logs(&submit.receipt_logs, submit.block_num)?;
596        } else {
597            self.register_self_note(&output, submit.block_num)?;
598            self.last_synced_block = submit.block_num;
599        }
600
601        self.utxos.mark_spent(nullifier_a);
602        self.utxos.mark_spent(nullifier_b);
603        let mut spent_nullifiers = vec![nullifier_a, nullifier_b];
604        if let Some(pn) = submit.payment_nullifier {
605            self.utxos.mark_spent(pn);
606            spent_nullifiers.push(pn);
607        }
608
609        for c in &excluded {
610            self.utxos.clear_pending_spend(c);
611        }
612
613        Ok(PrivacyTxResult {
614            tx_hash: submit.tx_hash,
615            new_commitments: vec![output.commitment],
616            spent_nullifiers,
617            gas_used: submit.gas_used,
618        })
619    }
620
621    #[allow(clippy::too_many_arguments)]
622    pub async fn transfer_with_transport(
623        &mut self,
624        transport: &Transport<'_>,
625        amount: U256,
626        asset: Address,
627        recipient_b: (U256, U256),
628        recipient_p: (U256, U256),
629        recipient_proof: crate::proof_inputs::DLEQProof,
630    ) -> Result<PrivacyTxResult, PrivacyClientError> {
631        use crate::crypto_helpers::random_bjj_scalar;
632        use crate::proof_inputs::NotePlaintext;
633
634        info!("Transferring {} of {:?}", amount, asset);
635
636        let balance = self.balance(asset);
637        if balance < amount {
638            return Err(PrivacyClientError::InsufficientBalance {
639                needed: amount,
640                have: balance,
641            });
642        }
643
644        let selected = self
645            .utxos
646            .select_notes(asset, amount)
647            .ok_or(PrivacyClientError::NoSpendableNotes)?;
648        let note = (*selected
649            .first()
650            .ok_or(PrivacyClientError::NoSpendableNotes)?)
651        .clone();
652        let excluded: HashSet<U256> = selected.iter().map(|n| n.commitment).collect();
653        drop(selected);
654
655        let spending_inputs = self.create_spending_inputs(&note)?;
656        let nullifier_hash = Self::derive_nullifier_hash(&note);
657
658        for c in &excluded {
659            self.utxos.mark_pending_spend(c);
660        }
661
662        // Memo note: nullifier=0, secret=0 because recipient uses Path B for spending
663        let memo_note = NotePlaintext {
664            asset_id: crate::crypto_helpers::address_to_field(asset),
665            value: amount,
666            secret: U256::zero(),
667            nullifier: U256::zero(),
668            timelock: U256::zero(),
669            hashlock: U256::zero(),
670        };
671        let memo_ephemeral_sk = random_bjj_scalar();
672
673        let change_value = note.plaintext.value.saturating_sub(amount);
674        let change_note = NotePlaintext::random(change_value, asset);
675        let change_ephemeral_sk = random_bjj_scalar();
676
677        self.ensure_synced_with_transport(transport).await?;
678
679        let merkle_root = self.tree.root();
680        let proof_bundle = self
681            .builder
682            .build_transfer(
683                &spending_inputs,
684                merkle_root,
685                recipient_b,
686                recipient_p,
687                recipient_proof,
688                memo_note,
689                memo_ephemeral_sk,
690                change_note.clone(),
691                change_ephemeral_sk,
692                0,
693            )
694            .await
695            .map_err(|e| PrivacyClientError::ProofFailed(e.to_string()))?;
696
697        debug!(
698            "Transfer proof generated. Memo: {:?}, Change: {:?}",
699            proof_bundle.memo_commitment, proof_bundle.change_commitment
700        );
701
702        let submit = self
703            .submit_and_confirm(
704                transport,
705                proof_bundle.calldata.clone(),
706                merkle_root,
707                &excluded,
708                U256::from(self.config.gas_limits.transfer),
709            )
710            .await?;
711
712        let mut new_commitments = vec![proof_bundle.memo_commitment];
713        if !change_value.is_zero() {
714            new_commitments.push(proof_bundle.change_commitment);
715        }
716
717        if submit.payment_nullifier.is_some() {
718            // Gas payment change note is inserted on-chain BEFORE action output notes
719            self.sync_from_receipt_logs(&submit.receipt_logs, submit.block_num)?;
720        } else {
721            // Memo FIRST, then change (matches DarkPool.sol insertion order)
722            self.tree.insert(proof_bundle.memo_commitment);
723
724            if !change_value.is_zero() {
725                let change_leaf_index = self.tree.insert(proof_bundle.change_commitment);
726
727                let change_shared_secret = crate::crypto_helpers::derive_shared_secret_bjj(
728                    change_ephemeral_sk,
729                    self.config.compliance_pk,
730                )
731                .map_err(|e| PrivacyClientError::CryptoFailed(format!("ECDH failed: {e}")))?;
732
733                self.utxos.add_note(
734                    OwnedNote {
735                        plaintext: change_note.clone(),
736                        commitment: proof_bundle.change_commitment,
737                        leaf_index: change_leaf_index,
738                        spending_secret: change_shared_secret,
739                        is_transfer: false,
740                        received_block: submit.block_num,
741                    },
742                    crate::crypto_helpers::derive_nullifier_path_a(change_note.nullifier),
743                );
744            }
745
746            self.last_synced_block = submit.block_num;
747        }
748
749        self.utxos.mark_spent(nullifier_hash);
750        let mut spent_nullifiers = vec![nullifier_hash];
751        if let Some(pn) = submit.payment_nullifier {
752            self.utxos.mark_spent(pn);
753            spent_nullifiers.push(pn);
754        }
755
756        for c in &excluded {
757            self.utxos.clear_pending_spend(c);
758        }
759
760        Ok(PrivacyTxResult {
761            tx_hash: submit.tx_hash,
762            new_commitments,
763            spent_nullifiers,
764            gas_used: submit.gas_used,
765        })
766    }
767
768    #[allow(clippy::too_many_arguments)]
769    pub async fn public_claim(
770        &mut self,
771        memo_id: U256,
772        value: U256,
773        asset: Address,
774        timelock: U256,
775        owner_pk: (U256, U256),
776        salt: U256,
777        recipient_sk: U256,
778    ) -> Result<PrivacyTxResult, PrivacyClientError> {
779        self.public_claim_with_transport(
780            &Transport::Direct,
781            memo_id,
782            value,
783            asset,
784            timelock,
785            owner_pk,
786            salt,
787            recipient_sk,
788        )
789        .await
790    }
791
792    /// `DarkPool.publicClaim()` verifies proof only (no `msg.sender` check), so it can be relayed.
793    #[allow(clippy::too_many_arguments)]
794    pub async fn public_claim_with_transport(
795        &mut self,
796        transport: &Transport<'_>,
797        memo_id: U256,
798        value: U256,
799        asset: Address,
800        timelock: U256,
801        owner_pk: (U256, U256),
802        salt: U256,
803        recipient_sk: U256,
804    ) -> Result<PrivacyTxResult, PrivacyClientError> {
805        use crate::crypto_helpers::{address_to_field, calculate_public_memo_id};
806
807        info!("Claiming public memo via transport: {:?}", memo_id);
808
809        let asset_id = address_to_field(asset);
810        let calculated_memo_id =
811            calculate_public_memo_id(value, asset_id, timelock, owner_pk.0, owner_pk.1, salt);
812        if calculated_memo_id != memo_id {
813            return Err(PrivacyClientError::InvalidMemo(
814                "Calculated memo ID does not match provided memo ID".to_string(),
815            ));
816        }
817
818        let note_out_result = self
819            .note_factory
820            .create_change_note(value, asset, &mut self.keys)
821            .map_err(|e| PrivacyClientError::ProofFailed(e.to_string()))?;
822
823        self.ensure_synced_with_transport(transport).await?;
824
825        let merkle_root = self.tree.root();
826
827        let proof_bundle = self
828            .builder
829            .build_public_claim(
830                memo_id,
831                value,
832                asset_id,
833                timelock,
834                owner_pk,
835                salt,
836                recipient_sk,
837                &note_out_result,
838            )
839            .await
840            .map_err(|e| PrivacyClientError::ProofFailed(e.to_string()))?;
841
842        debug!(
843            "Public claim proof generated. Output commitment: {:?}",
844            proof_bundle.note_out.commitment
845        );
846
847        let excluded = HashSet::new();
848
849        let submit = self
850            .submit_and_confirm(
851                transport,
852                proof_bundle.calldata.clone(),
853                merkle_root,
854                &excluded,
855                U256::from(self.config.gas_limits.public_claim),
856            )
857            .await?;
858
859        if submit.payment_nullifier.is_some() {
860            self.sync_from_receipt_logs(&submit.receipt_logs, submit.block_num)?;
861        } else {
862            self.register_self_note(&note_out_result, submit.block_num)?;
863            self.last_synced_block = submit.block_num;
864        }
865
866        let mut spent_nullifiers = vec![];
867        if let Some(pn) = submit.payment_nullifier {
868            self.utxos.mark_spent(pn);
869            spent_nullifiers.push(pn);
870        }
871
872        Ok(PrivacyTxResult {
873            tx_hash: submit.tx_hash,
874            new_commitments: vec![note_out_result.commitment],
875            spent_nullifiers,
876            gas_used: submit.gas_used,
877        })
878    }
879
880    pub async fn sync(&mut self) -> Result<ScanResult, PrivacyClientError> {
881        let current_block = self
882            .timed_provider_call("sync get_block_number", self.signer.get_block_number())
883            .await?
884            .as_u64();
885
886        if current_block <= self.last_synced_block {
887            return Ok(ScanResult::default());
888        }
889
890        info!(
891            "Syncing from block {} to {}",
892            self.last_synced_block + 1,
893            current_block
894        );
895
896        let taken_utxos = std::mem::take(&mut self.utxos);
897        let taken_tree = std::mem::take(&mut self.tree);
898
899        let provider_arc = Arc::new(self.signer.inner().clone());
900        let mut scan_engine = ScanEngine::with_state(
901            provider_arc,
902            self.config.darkpool_address,
903            self.keys.clone(),
904            taken_utxos,
905            taken_tree,
906            self.config.compliance_pk,
907            self.last_synced_block,
908        );
909
910        let result = match scan_engine
911            .scan_blocks(self.last_synced_block + 1, current_block)
912            .await
913        {
914            Ok(result) => result,
915            Err(e) => {
916                self.utxos = std::mem::take(scan_engine.utxos_mut());
917                self.tree = std::mem::take(scan_engine.tree_mut());
918                return Err(PrivacyClientError::ScanError(e.to_string()));
919            }
920        };
921
922        self.utxos = std::mem::take(scan_engine.utxos_mut());
923        self.tree = std::mem::take(scan_engine.tree_mut());
924        self.last_synced_block = current_block;
925
926        if !result.new_notes.is_empty() {
927            self.advance_keys(10);
928        }
929
930        info!(
931            "Sync complete: {} new notes, {} nullifiers spent",
932            result.new_notes.len(),
933            result.spent_nullifiers.len()
934        );
935
936        // Post-sync root verification: check that the local root is recognized
937        // on-chain. This is non-fatal because the chain may have advanced past
938        // our sync range (new leaves inserted after `current_block`). The pre-proof
939        // check in `ensure_synced_with_transport` is the hard gate.
940        if let Err(e) = self.verify_root_is_known().await {
941            warn!("Post-sync root verification failed (may need re-sync): {e}");
942        }
943
944        Ok(result)
945    }
946
947    /// Syncs via mixnet when transport carries a mixnet client (avoids metadata leaks).
948    #[cfg_attr(not(feature = "mixnet"), allow(unused_variables))]
949    async fn ensure_synced_with_transport(
950        &mut self,
951        transport: &Transport<'_>,
952    ) -> Result<(), PrivacyClientError> {
953        #[cfg(feature = "mixnet")]
954        match transport {
955            Transport::PaidMixnet { client, .. } | Transport::SignedBroadcast { client } => {
956                let current_block = client.block_number().await?;
957                if current_block > self.last_synced_block {
958                    let behind = current_block - self.last_synced_block;
959                    warn!(
960                        "Merkle tree is {} blocks behind chain tip ({} vs {}). Auto-syncing via mixnet.",
961                        behind, self.last_synced_block, current_block
962                    );
963                    self.sync_via_mixnet(client).await?;
964                }
965                self.verify_root_is_known().await?;
966                return Ok(());
967            }
968            Transport::Direct => {}
969        }
970
971        let current_block = self
972            .timed_provider_call(
973                "ensure_synced get_block_number",
974                self.signer.get_block_number(),
975            )
976            .await?
977            .as_u64();
978
979        if current_block > self.last_synced_block {
980            let behind = current_block - self.last_synced_block;
981            warn!(
982                "Merkle tree is {} blocks behind chain tip ({} vs {}). Auto-syncing before proof generation.",
983                behind, self.last_synced_block, current_block
984            );
985            self.sync().await?;
986        }
987
988        self.verify_root_is_known().await?;
989        Ok(())
990    }
991
992    /// Wraps provider calls with a timeout to prevent indefinite hangs.
993    async fn timed_provider_call<T, E, F>(
994        &self,
995        op_name: &str,
996        future: F,
997    ) -> Result<T, PrivacyClientError>
998    where
999        F: std::future::Future<Output = Result<T, E>>,
1000        E: std::fmt::Display,
1001    {
1002        tokio::time::timeout(
1003            Duration::from_millis(self.config.provider_timeout_ms),
1004            future,
1005        )
1006        .await
1007        .map_err(|_| {
1008            PrivacyClientError::ProviderError(format!(
1009                "{op_name} timed out after {}ms",
1010                self.config.provider_timeout_ms
1011            ))
1012        })?
1013        .map_err(|e| PrivacyClientError::ProviderError(format!("{op_name}: {e}")))
1014    }
1015
1016    /// Sync from TX receipt logs (bypasses `eth_getLogs` which returns 0 on Anvil).
1017    fn sync_from_receipt_logs(
1018        &mut self,
1019        logs: &[ethers::types::Log],
1020        block_num: u64,
1021    ) -> Result<ScanResult, PrivacyClientError> {
1022        if logs.is_empty() {
1023            warn!("Paid TX receipt has 0 logs -- TX may have reverted silently");
1024            return Ok(ScanResult::default());
1025        }
1026
1027        let provider_arc = Arc::new(self.signer.inner().clone());
1028        let mut scan_engine = ScanEngine::with_state(
1029            provider_arc,
1030            self.config.darkpool_address,
1031            self.keys.clone(),
1032            std::mem::take(&mut self.utxos),
1033            std::mem::take(&mut self.tree),
1034            self.config.compliance_pk,
1035            self.last_synced_block,
1036        );
1037
1038        let result = scan_engine
1039            .process_logs_directly(logs)
1040            .map_err(|e| PrivacyClientError::ScanError(e.to_string()))?;
1041
1042        self.utxos = std::mem::take(scan_engine.utxos_mut());
1043        self.tree = std::mem::take(scan_engine.tree_mut());
1044        self.last_synced_block = block_num;
1045
1046        if !result.new_notes.is_empty() {
1047            self.advance_keys(10);
1048        }
1049
1050        info!(
1051            "Receipt log sync: {} new notes, {} nullifiers spent, {} commitments added",
1052            result.new_notes.len(),
1053            result.spent_nullifiers.len(),
1054            result.new_commitments.len()
1055        );
1056
1057        Ok(result)
1058    }
1059
1060    #[cfg(feature = "mixnet")]
1061    pub async fn sync_via_mixnet(
1062        &mut self,
1063        mixnet: &MixnetClient,
1064    ) -> Result<ScanResult, PrivacyClientError> {
1065        let current_block = mixnet.block_number().await?;
1066
1067        if current_block <= self.last_synced_block {
1068            return Ok(ScanResult::default());
1069        }
1070
1071        info!(
1072            "Syncing via mixnet from block {} to {}",
1073            self.last_synced_block + 1,
1074            current_block
1075        );
1076
1077        let filter = Filter::new()
1078            .address(self.config.darkpool_address)
1079            .from_block(self.last_synced_block + 1)
1080            .to_block(current_block);
1081
1082        let logs = mixnet.get_logs(&filter).await?;
1083
1084        info!("Received {} logs via mixnet", logs.len());
1085
1086        let provider_arc = Arc::new(self.signer.inner().clone());
1087        let mut scan_engine = ScanEngine::with_state(
1088            provider_arc,
1089            self.config.darkpool_address,
1090            self.keys.clone(),
1091            std::mem::take(&mut self.utxos),
1092            std::mem::take(&mut self.tree),
1093            self.config.compliance_pk,
1094            self.last_synced_block,
1095        );
1096
1097        let result = scan_engine
1098            .process_logs_directly(&logs)
1099            .map_err(|e| PrivacyClientError::ScanError(e.to_string()))?;
1100
1101        self.utxos = std::mem::take(scan_engine.utxos_mut());
1102        self.tree = std::mem::take(scan_engine.tree_mut());
1103        self.last_synced_block = current_block;
1104
1105        if !result.new_notes.is_empty() {
1106            self.advance_keys(10);
1107        }
1108
1109        info!(
1110            "Mixnet sync complete: {} new notes, {} nullifiers spent",
1111            result.new_notes.len(),
1112            result.spent_nullifiers.len()
1113        );
1114
1115        Ok(result)
1116    }
1117
1118    /// Direct-submission wrapper for [`withdraw_with_transport`](Self::withdraw_with_transport).
1119    pub async fn withdraw(
1120        &mut self,
1121        amount: U256,
1122        asset: Address,
1123        recipient: Address,
1124        intent_hash: Option<U256>,
1125    ) -> Result<PrivacyTxResult, PrivacyClientError> {
1126        self.withdraw_with_transport(&Transport::Direct, amount, asset, recipient, intent_hash)
1127            .await
1128    }
1129
1130    /// Direct-submission wrapper for [`split_with_transport`](Self::split_with_transport).
1131    pub async fn split(
1132        &mut self,
1133        amount_a: U256,
1134        amount_b: U256,
1135        asset: Address,
1136    ) -> Result<PrivacyTxResult, PrivacyClientError> {
1137        self.split_with_transport(&Transport::Direct, amount_a, amount_b, asset)
1138            .await
1139    }
1140
1141    /// Direct-submission wrapper for [`join_with_transport`](Self::join_with_transport).
1142    pub async fn join(&mut self, asset: Address) -> Result<PrivacyTxResult, PrivacyClientError> {
1143        self.join_with_transport(&Transport::Direct, asset).await
1144    }
1145
1146    /// Direct-submission wrapper for [`transfer_with_transport`](Self::transfer_with_transport).
1147    pub async fn transfer(
1148        &mut self,
1149        amount: U256,
1150        asset: Address,
1151        recipient_b: (U256, U256),
1152        recipient_p: (U256, U256),
1153        recipient_proof: crate::proof_inputs::DLEQProof,
1154    ) -> Result<PrivacyTxResult, PrivacyClientError> {
1155        self.transfer_with_transport(
1156            &Transport::Direct,
1157            amount,
1158            asset,
1159            recipient_b,
1160            recipient_p,
1161            recipient_proof,
1162        )
1163        .await
1164    }
1165
1166    /// Path A for self-created notes, Path B for received transfer notes.
1167    fn derive_nullifier_hash(note: &OwnedNote) -> U256 {
1168        if note.is_transfer {
1169            crate::crypto_helpers::derive_nullifier_path_b(
1170                note.spending_secret,
1171                note.commitment,
1172                note.leaf_index,
1173            )
1174        } else {
1175            crate::crypto_helpers::derive_nullifier_path_a(note.plaintext.nullifier)
1176        }
1177    }
1178
1179    /// Insert a self-created note (Path A) into the Merkle tree and UTXO store.
1180    fn register_self_note(
1181        &mut self,
1182        note_result: &ChangeNoteResult,
1183        block_num: u64,
1184    ) -> Result<u64, PrivacyClientError> {
1185        let leaf_index = self.tree.insert(note_result.commitment);
1186
1187        let shared_secret = crate::crypto_helpers::derive_shared_secret_bjj(
1188            note_result.ephemeral_sk,
1189            self.config.compliance_pk,
1190        )
1191        .map_err(|e| PrivacyClientError::CryptoFailed(format!("ECDH failed: {e}")))?;
1192
1193        self.utxos.add_note(
1194            OwnedNote {
1195                plaintext: note_result.note.clone(),
1196                commitment: note_result.commitment,
1197                leaf_index,
1198                spending_secret: shared_secret,
1199                is_transfer: false,
1200                received_block: block_num,
1201            },
1202            crate::crypto_helpers::derive_nullifier_path_a(note_result.note.nullifier),
1203        );
1204
1205        Ok(leaf_index)
1206    }
1207
1208    /// Submit and confirm via the appropriate transport.
1209    #[cfg_attr(not(feature = "mixnet"), allow(unused_variables))]
1210    async fn submit_and_confirm(
1211        &mut self,
1212        transport: &Transport<'_>,
1213        calldata: Bytes,
1214        merkle_root: U256,
1215        excluded: &HashSet<U256>,
1216        gas_limit: U256,
1217    ) -> Result<SubmitResult, PrivacyClientError> {
1218        match transport {
1219            Transport::Direct => {
1220                let tx = TransactionRequest::new()
1221                    .to(self.config.darkpool_address)
1222                    .data(calldata);
1223
1224                let pending = self
1225                    .timed_provider_call(
1226                        "submit send_transaction",
1227                        self.signer.send_transaction(tx, None),
1228                    )
1229                    .await?;
1230
1231                let receipt = self
1232                    .timed_provider_call("submit pending confirmation", pending)
1233                    .await?
1234                    .ok_or_else(|| {
1235                        PrivacyClientError::TransactionFailed("No receipt received".to_string())
1236                    })?;
1237
1238                info!(
1239                    "Transaction confirmed. TxHash: {:?}",
1240                    receipt.transaction_hash
1241                );
1242
1243                let block_num = if let Some(b) = receipt.block_number {
1244                    b.as_u64()
1245                } else {
1246                    warn!(
1247                        "Receipt missing block_number for tx {:?}, using last_synced_block={}",
1248                        receipt.transaction_hash, self.last_synced_block
1249                    );
1250                    self.last_synced_block
1251                };
1252                let gas_used = receipt.gas_used.unwrap_or_else(|| {
1253                    warn!(
1254                        "Receipt missing gas_used for tx {:?}",
1255                        receipt.transaction_hash
1256                    );
1257                    U256::zero()
1258                });
1259
1260                Ok(SubmitResult {
1261                    tx_hash: receipt.transaction_hash,
1262                    block_num,
1263                    gas_used,
1264                    payment_nullifier: None,
1265                    receipt_logs: vec![],
1266                })
1267            }
1268            #[cfg(feature = "mixnet")]
1269            Transport::PaidMixnet {
1270                client,
1271                payment_asset,
1272                prices,
1273                relayer_address,
1274            } => {
1275                // ZK verification gas varies hugely between Anvil and mainnet
1276                let simulated_gas = self
1277                    .timed_provider_call(
1278                        "action eth_estimateGas",
1279                        self.signer.estimate_gas(
1280                            &ethers::types::transaction::eip2718::TypedTransaction::Legacy(
1281                                TransactionRequest::new()
1282                                    .to(self.config.darkpool_address)
1283                                    .data(calldata.clone()),
1284                            ),
1285                            None,
1286                        ),
1287                    )
1288                    .await
1289                    .unwrap_or_else(|e| {
1290                        warn!(
1291                            "eth_estimateGas failed ({}), falling back to config gas_limit={}",
1292                            e, gas_limit
1293                        );
1294                        gas_limit
1295                    });
1296
1297                // 20% buffer + gas_payment verification overhead
1298                let action_gas_buffered = simulated_gas + simulated_gas / U256::from(5);
1299                let total_gas = action_gas_buffered + simulated_gas;
1300                info!(
1301                    "Gas estimate: simulated={}, buffered={}, total_with_payment={}",
1302                    simulated_gas, action_gas_buffered, total_gas
1303                );
1304
1305                let payment_note = self.select_payment_note_excluding(
1306                    *payment_asset,
1307                    prices,
1308                    excluded,
1309                    total_gas,
1310                )?;
1311
1312                // Registered ephemeral key so scan engine can discover the change note
1313                let fee_estimate = self.builder.estimate_fee(total_gas, prices);
1314                let change_value = payment_note
1315                    .note
1316                    .value
1317                    .checked_sub(fee_estimate.fee_amount)
1318                    .ok_or(PrivacyClientError::GasFeeExceedsNoteValue {
1319                        fee: fee_estimate.fee_amount,
1320                        note_value: payment_note.note.value,
1321                    })?;
1322                let gas_change_note = self
1323                    .note_factory
1324                    .create_change_note(change_value, *payment_asset, &mut self.keys)
1325                    .map_err(|e| {
1326                        PrivacyClientError::ProofFailed(format!(
1327                            "Gas change note creation failed: {e}"
1328                        ))
1329                    })?;
1330
1331                let bundle = self
1332                    .builder
1333                    .build_paid_action(
1334                        &payment_note,
1335                        merkle_root,
1336                        self.tree.get_path(payment_note.leaf_index).siblings_vec(),
1337                        self.config.darkpool_address,
1338                        calldata,
1339                        prices,
1340                        *relayer_address,
1341                        total_gas,
1342                        Some(gas_change_note),
1343                        0,
1344                    )
1345                    .await
1346                    .map_err(|e| PrivacyClientError::ProofFailed(e.to_string()))?;
1347
1348                debug!(
1349                    "Paid action bundle built: fee={}, multicall_target={:?}",
1350                    bundle.gas_payment.fee_amount, bundle.multicall_target
1351                );
1352
1353                let tx_hash = client
1354                    .submit_transaction(bundle.multicall_target, bundle.multicall_data.clone())
1355                    .await?;
1356
1357                info!(
1358                    "Paid transaction submitted via mixnet. TxHash: {:?}",
1359                    tx_hash
1360                );
1361
1362                let block_num = self.wait_for_receipt_via_mixnet(client, tx_hash).await?;
1363
1364                // Direct receipt fetch: verify success + capture logs (eth_getLogs unreliable on Anvil)
1365                let receipt = self
1366                    .timed_provider_call(
1367                        "paid TX get_transaction_receipt",
1368                        self.signer.get_transaction_receipt(tx_hash),
1369                    )
1370                    .await?
1371                    .ok_or_else(|| {
1372                        PrivacyClientError::TransactionFailed(
1373                            "Paid TX receipt not found via direct provider".to_string(),
1374                        )
1375                    })?;
1376
1377                if receipt.status != Some(U64::from(1)) {
1378                    return Err(PrivacyClientError::TransactionFailed(format!(
1379                        "Paid TX reverted on-chain (status={:?}, tx={:?})",
1380                        receipt.status, tx_hash
1381                    )));
1382                }
1383
1384                info!(
1385                    "Paid TX confirmed: block={}, logs={}, gas_used={:?}",
1386                    block_num,
1387                    receipt.logs.len(),
1388                    receipt.gas_used
1389                );
1390
1391                // Events may be at a different block than what the mixnet receipt reported
1392                let final_logs = if receipt.logs.is_empty() {
1393                    warn!(
1394                        "Receipt has 0 logs despite status=1 and gas_used={:?}. \
1395                         Direct receipt block={:?}. Checking surrounding blocks...",
1396                        receipt.gas_used, receipt.block_number
1397                    );
1398
1399                    let search_from = block_num.saturating_sub(5);
1400                    let current = self
1401                        .timed_provider_call(
1402                            "paid TX fallback get_block_number",
1403                            self.signer.get_block_number(),
1404                        )
1405                        .await
1406                        .map(|b| b.as_u64())
1407                        .unwrap_or(block_num + 5);
1408                    let search_to = current.max(block_num + 5);
1409
1410                    let wide_filter = Filter::new()
1411                        .address(self.config.darkpool_address)
1412                        .from_block(search_from)
1413                        .to_block(search_to);
1414                    match self
1415                        .timed_provider_call(
1416                            "paid TX fallback get_logs",
1417                            self.signer.get_logs(&wide_filter),
1418                        )
1419                        .await
1420                    {
1421                        Ok(all_logs) => {
1422                            info!(
1423                                "Blocks {}-{} have {} DarkPool logs",
1424                                search_from,
1425                                search_to,
1426                                all_logs.len()
1427                            );
1428                            for (i, log) in all_logs.iter().take(10).enumerate() {
1429                                info!(
1430                                    "  Log[{}]: block={:?}, addr={:?}, topics={}, tx={:?}",
1431                                    i,
1432                                    log.block_number,
1433                                    log.address,
1434                                    log.topics.len(),
1435                                    log.transaction_hash
1436                                );
1437                            }
1438
1439                            let tx_logs: Vec<_> = all_logs
1440                                .into_iter()
1441                                .filter(|l| l.transaction_hash == Some(tx_hash))
1442                                .collect();
1443
1444                            if tx_logs.is_empty() {
1445                                warn!(
1446                                    "No logs found for TX {:?} in blocks {}-{}",
1447                                    tx_hash, search_from, search_to
1448                                );
1449                                vec![]
1450                            } else {
1451                                info!(
1452                                    "Found {} logs for TX {:?} in wider search",
1453                                    tx_logs.len(),
1454                                    tx_hash
1455                                );
1456                                tx_logs
1457                            }
1458                        }
1459                        Err(e) => {
1460                            warn!("Failed to query wide log range: {}", e);
1461                            vec![]
1462                        }
1463                    }
1464                } else {
1465                    receipt.logs
1466                };
1467
1468                Ok(SubmitResult {
1469                    tx_hash,
1470                    block_num,
1471                    gas_used: receipt.gas_used.unwrap_or_default(),
1472                    payment_nullifier: Some(payment_note.nullifier),
1473                    receipt_logs: final_logs,
1474                })
1475            }
1476            #[cfg(feature = "mixnet")]
1477            Transport::SignedBroadcast { client } => {
1478                let tx = TransactionRequest::new()
1479                    .to(self.config.darkpool_address)
1480                    .data(calldata);
1481
1482                let mut typed_tx = TypedTransaction::Legacy(tx);
1483                self.signer
1484                    .fill_transaction(&mut typed_tx, None)
1485                    .await
1486                    .map_err(|e| {
1487                        PrivacyClientError::TransactionFailed(format!(
1488                            "fill_transaction for signed broadcast: {e}"
1489                        ))
1490                    })?;
1491
1492                let signature = self
1493                    .signer
1494                    .signer()
1495                    .sign_transaction(&typed_tx)
1496                    .await
1497                    .map_err(|e| {
1498                        PrivacyClientError::TransactionFailed(format!(
1499                            "sign_transaction for signed broadcast: {e}"
1500                        ))
1501                    })?;
1502
1503                let raw_tx = typed_tx.rlp_signed(&signature);
1504                let tx_hash = client.broadcast_signed_transaction(raw_tx).await?;
1505
1506                info!(
1507                    "Signed broadcast submitted via mixnet. TxHash: {:?}",
1508                    tx_hash
1509                );
1510
1511                let block_num = self.wait_for_receipt_via_mixnet(client, tx_hash).await?;
1512
1513                let receipt = self
1514                    .timed_provider_call(
1515                        "signed_broadcast get_transaction_receipt",
1516                        self.signer.get_transaction_receipt(tx_hash),
1517                    )
1518                    .await?
1519                    .ok_or_else(|| {
1520                        PrivacyClientError::TransactionFailed(
1521                            "Signed broadcast receipt not found".into(),
1522                        )
1523                    })?;
1524
1525                if receipt.status != Some(U64::from(1)) {
1526                    return Err(PrivacyClientError::TransactionFailed(format!(
1527                        "Signed broadcast TX reverted (status={:?}, tx={:?})",
1528                        receipt.status, tx_hash
1529                    )));
1530                }
1531
1532                let block_num = receipt.block_number.map_or(block_num, |b| b.as_u64());
1533                let gas_used = receipt.gas_used.unwrap_or_default();
1534
1535                Ok(SubmitResult {
1536                    tx_hash,
1537                    block_num,
1538                    gas_used,
1539                    payment_nullifier: None,
1540                    receipt_logs: vec![],
1541                })
1542            }
1543            #[cfg(not(feature = "mixnet"))]
1544            Transport::_Phantom(_) => unreachable!(),
1545        }
1546    }
1547
1548    fn create_spending_inputs(
1549        &self,
1550        note: &OwnedNote,
1551    ) -> Result<SpendingInputs, PrivacyClientError> {
1552        let merkle_path = self.tree.get_path(note.leaf_index);
1553        Ok(SpendingInputs::from_owned_note(note, merkle_path))
1554    }
1555
1556    #[cfg(feature = "mixnet")]
1557    async fn wait_for_receipt_via_mixnet(
1558        &self,
1559        mixnet: &MixnetClient,
1560        tx_hash: H256,
1561    ) -> Result<u64, PrivacyClientError> {
1562        let max_attempts = 30;
1563        let poll_interval = std::time::Duration::from_secs(2);
1564
1565        for attempt in 0..max_attempts {
1566            match mixnet.get_transaction_receipt(tx_hash).await {
1567                Ok(Some(receipt)) => {
1568                    if let Some(block_num) = receipt.get("blockNumber") {
1569                        if let Some(hex_str) = block_num.as_str() {
1570                            match u64::from_str_radix(hex_str.trim_start_matches("0x"), 16) {
1571                                Ok(num) => return Ok(num),
1572                                Err(e) => {
1573                                    warn!(
1574                                        "Malformed blockNumber '{}' in receipt: {}. Falling back to current block.",
1575                                        hex_str, e
1576                                    );
1577                                }
1578                            }
1579                        }
1580                    }
1581                    return Ok(mixnet.block_number().await?);
1582                }
1583                Ok(None) => {
1584                    debug!(
1585                        "Waiting for receipt via mixnet (attempt {}/{})",
1586                        attempt + 1,
1587                        max_attempts
1588                    );
1589                    tokio::time::sleep(poll_interval).await;
1590                }
1591                Err(e) => {
1592                    warn!("Error fetching receipt via mixnet: {}", e);
1593                    tokio::time::sleep(poll_interval).await;
1594                }
1595            }
1596        }
1597
1598        Err(PrivacyClientError::TransactionFailed(
1599            "Timed out waiting for receipt".to_string(),
1600        ))
1601    }
1602
1603    /// Excludes `exclude` commitments to prevent double-spend when asset == `payment_asset`.
1604    #[cfg(feature = "mixnet")]
1605    fn select_payment_note_excluding(
1606        &self,
1607        payment_asset: Address,
1608        prices: &PriceData,
1609        exclude: &HashSet<U256>,
1610        gas_limit: U256,
1611    ) -> Result<WalletNote, PrivacyClientError> {
1612        use crate::economics::FeeManager;
1613
1614        let fee_manager = FeeManager::default();
1615        let estimate = fee_manager.calculate_fee(gas_limit, prices);
1616
1617        let payment_notes: Vec<_> = self
1618            .utxos
1619            .get_unspent_excluding(exclude)
1620            .into_iter()
1621            .filter(|n| {
1622                n.plaintext.asset_id == crate::crypto_helpers::address_to_field(payment_asset)
1623                    && n.plaintext.value >= estimate.fee_amount
1624            })
1625            .cloned()
1626            .collect();
1627
1628        let selected = payment_notes
1629            .first()
1630            .ok_or(PrivacyClientError::NoteSelectionFailed(format!(
1631                "No note for gas payment (need {}, excluding {} notes). User may need to deposit more tokens or use a different payment asset.",
1632                estimate.fee_amount, exclude.len()
1633            )))?;
1634
1635        Ok(self.owned_note_to_wallet_note(selected))
1636    }
1637
1638    /// Derives nullifier using the correct path (A or B) based on note origin.
1639    #[cfg(feature = "mixnet")]
1640    fn owned_note_to_wallet_note(&self, owned: &OwnedNote) -> WalletNote {
1641        let nullifier = Self::derive_nullifier_hash(owned);
1642
1643        WalletNote {
1644            note: owned.plaintext.clone(),
1645            commitment: owned.commitment,
1646            leaf_index: owned.leaf_index,
1647            nullifier,
1648            spending_secret: owned.spending_secret,
1649            is_transfer: owned.is_transfer,
1650            derivation_index: 0,
1651            spent: false,
1652        }
1653    }
1654
1655    /// Post-sync sanity check: compares local root against `getCurrentRoot()` on-chain.
1656    pub async fn verify_root_matches_chain(&self) -> Result<(), PrivacyClientError> {
1657        let onchain_root = self.fetch_current_root().await?;
1658        let local_root = self.tree.root();
1659
1660        if local_root != onchain_root {
1661            return Err(PrivacyClientError::TreeMismatch {
1662                local: local_root,
1663                onchain: onchain_root,
1664            });
1665        }
1666
1667        debug!(root = ?local_root, "Local Merkle root matches on-chain root");
1668        Ok(())
1669    }
1670
1671    /// Pre-proof check: verifies local root is in the contract's root history ring buffer.
1672    pub async fn verify_root_is_known(&self) -> Result<(), PrivacyClientError> {
1673        let local_root = self.tree.root();
1674        let is_known = self.fetch_is_known_root(local_root).await?;
1675
1676        if !is_known {
1677            // Fetch the current root to provide a useful error message
1678            let onchain_root = self.fetch_current_root().await.unwrap_or(U256::zero());
1679            return Err(PrivacyClientError::TreeMismatch {
1680                local: local_root,
1681                onchain: onchain_root,
1682            });
1683        }
1684
1685        debug!(root = ?local_root, "Local Merkle root is recognized on-chain");
1686        Ok(())
1687    }
1688
1689    /// Selector `0x8270482d` = `getCurrentRoot()`, returns big-endian U256.
1690    async fn fetch_current_root(&self) -> Result<U256, PrivacyClientError> {
1691        let selector: [u8; 4] = [0x82, 0x70, 0x48, 0x2d]; // getCurrentRoot()
1692
1693        let tx = TransactionRequest::new()
1694            .to(self.config.darkpool_address)
1695            .data(selector.to_vec());
1696
1697        let result = self
1698            .timed_provider_call("getCurrentRoot", self.signer.call(&tx.into(), None))
1699            .await?;
1700
1701        if result.len() < 32 {
1702            return Err(PrivacyClientError::ProviderError(format!(
1703                "getCurrentRoot returned {} bytes, expected 32",
1704                result.len()
1705            )));
1706        }
1707
1708        Ok(U256::from_big_endian(&result[..32]))
1709    }
1710
1711    /// Selector `0x6d9833e3` = `isKnownRoot(bytes32)`, returns ABI-encoded bool.
1712    async fn fetch_is_known_root(&self, root: U256) -> Result<bool, PrivacyClientError> {
1713        let selector: [u8; 4] = [0x6d, 0x98, 0x33, 0xe3]; // isKnownRoot(bytes32)
1714
1715        let mut calldata = Vec::with_capacity(36);
1716        calldata.extend_from_slice(&selector);
1717        let mut root_bytes = [0u8; 32];
1718        root.to_big_endian(&mut root_bytes);
1719        calldata.extend_from_slice(&root_bytes);
1720
1721        let tx = TransactionRequest::new()
1722            .to(self.config.darkpool_address)
1723            .data(calldata);
1724
1725        let result = self
1726            .timed_provider_call("isKnownRoot", self.signer.call(&tx.into(), None))
1727            .await?;
1728
1729        if result.len() < 32 {
1730            return Err(PrivacyClientError::ProviderError(format!(
1731                "isKnownRoot returned {} bytes, expected 32",
1732                result.len()
1733            )));
1734        }
1735
1736        Ok(result[31] != 0)
1737    }
1738
1739    #[must_use]
1740    pub fn utxos(&self) -> &UtxoStore {
1741        &self.utxos
1742    }
1743
1744    #[must_use]
1745    pub fn tree(&self) -> &LocalMerkleTree {
1746        &self.tree
1747    }
1748
1749    #[must_use]
1750    pub fn keys(&self) -> &KeyRepository {
1751        &self.keys
1752    }
1753
1754    #[must_use]
1755    pub fn darkpool_address(&self) -> Address {
1756        self.config.darkpool_address
1757    }
1758
1759    #[must_use]
1760    pub fn builder(&self) -> &TransactionBuilder {
1761        &self.builder
1762    }
1763}
1764
1765#[cfg(test)]
1766mod tests {
1767    use super::*;
1768
1769    #[test]
1770    fn test_privacy_client_config_default() {
1771        let config = PrivacyClientConfig::default();
1772        assert_eq!(config.darkpool_address, Address::zero());
1773        assert_eq!(config.start_block, 0);
1774    }
1775
1776    #[test]
1777    fn test_privacy_tx_result() {
1778        let result = PrivacyTxResult {
1779            tx_hash: H256::zero(),
1780            new_commitments: vec![U256::from(1), U256::from(2)],
1781            spent_nullifiers: vec![U256::from(3)],
1782            gas_used: U256::from(21000),
1783        };
1784
1785        assert_eq!(result.new_commitments.len(), 2);
1786        assert_eq!(result.spent_nullifiers.len(), 1);
1787    }
1788}