1use 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#[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
74pub enum Transport<'a> {
76 Direct,
77 #[cfg(feature = "mixnet")]
80 PaidMixnet {
81 client: &'a MixnetClient,
82 payment_asset: Address,
83 prices: &'a PriceData,
84 relayer_address: Address,
85 },
86 #[cfg(feature = "mixnet")]
89 SignedBroadcast {
90 client: &'a MixnetClient,
91 },
92 #[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 receipt_logs: Vec<ethers::types::Log>,
105}
106
107pub 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 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 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 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 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 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(¬e)?;
363 let nullifier_hash = Self::derive_nullifier_hash(¬e);
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 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 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(¬e)?;
462 let nullifier_hash = Self::derive_nullifier_hash(¬e);
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, ¬e_a, ¬e_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 self.sync_from_receipt_logs(&submit.receipt_logs, submit.block_num)?;
500 } else {
501 self.register_self_note(¬e_a, submit.block_num)?;
502 self.register_self_note(¬e_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 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(¬e)?;
656 let nullifier_hash = Self::derive_nullifier_hash(¬e);
657
658 for c in &excluded {
659 self.utxos.mark_pending_spend(c);
660 }
661
662 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 self.sync_from_receipt_logs(&submit.receipt_logs, submit.block_num)?;
720 } else {
721 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 #[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 ¬e_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(¬e_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 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 #[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 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 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 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 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 pub async fn join(&mut self, asset: Address) -> Result<PrivacyTxResult, PrivacyClientError> {
1143 self.join_with_transport(&Transport::Direct, asset).await
1144 }
1145
1146 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 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 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 #[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 let simulated_gas = self
1277 .timed_provider_call(
1278 "action eth_estimateGas",
1279 self.signer.estimate_gas(
1280 ðers::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 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 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 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 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 #[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 #[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 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 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 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 async fn fetch_current_root(&self) -> Result<U256, PrivacyClientError> {
1691 let selector: [u8; 4] = [0x82, 0x70, 0x48, 0x2d]; 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 async fn fetch_is_known_root(&self, root: U256) -> Result<bool, PrivacyClientError> {
1713 let selector: [u8; 4] = [0x6d, 0x98, 0x33, 0xe3]; 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}