Skip to main content

darkpool_client/
builder.rs

1//! ZK proof generation and transaction encoding for `DarkPool` operations.
2
3use ethers::abi::{encode, Token};
4use ethers::types::{Address, Bytes, H256, U256};
5use std::sync::{Arc, LazyLock};
6use thiserror::Error;
7use tracing::{debug, info};
8
9/// BN254 scalar field modulus
10#[allow(clippy::expect_used)]
11static BN254_MODULUS: LazyLock<U256> = LazyLock::new(|| {
12    U256::from_dec_str(
13        "21888242871839275222246405745257275088548364400416034343698204186575808495617",
14    )
15    .expect("BN254 modulus constant is a valid decimal string")
16});
17
18use crate::crypto_helpers::field_to_address;
19use crate::economics::{FeeConfig, FeeEstimate, FeeManager, PriceData};
20use crate::note_factory::{ChangeNoteResult, DepositNoteResult, SpendingInputs};
21use crate::note_processor::WalletNote;
22use crate::proof_inputs::{
23    DLEQProof, DepositInputs, GasPaymentInputs, JoinInputs, NotePlaintext, PublicClaimInputs,
24    SplitInputs, TransferInputs, WithdrawInputs,
25};
26use crate::prover::ClientProver;
27use nox_core::traits::interfaces::InfrastructureError;
28
29#[derive(Debug, Error)]
30pub enum BuilderError {
31    #[error("Proof generation failed: {0}")]
32    ProofGeneration(String),
33    #[error("Insufficient funds: need {needed}, have {available}")]
34    InsufficientFunds { needed: U256, available: U256 },
35    #[error("No suitable note found for payment")]
36    NoSuitableNote,
37    #[error("Encoding error: {0}")]
38    Encoding(String),
39    #[error("Configuration error: {0}")]
40    Config(String),
41    #[error("Infrastructure error: {0}")]
42    Infrastructure(#[from] InfrastructureError),
43}
44
45/// A complete gas payment bundle ready for relay
46#[derive(Debug, Clone)]
47pub struct GasPaymentBundle {
48    pub proof: Vec<u8>,
49    pub public_inputs: Vec<[u8; 32]>,
50    pub public_inputs_hex: Vec<String>,
51    pub nullifier_hash: H256,
52    pub fee_amount: U256,
53    /// Binds the payment to a specific action
54    pub execution_hash: H256,
55}
56
57#[derive(Debug, Clone)]
58pub struct MulticallBundle {
59    pub gas_payment: GasPaymentBundle,
60    pub multicall_data: Bytes,
61    pub multicall_target: Address,
62}
63
64#[derive(Debug, Clone)]
65pub struct DepositProofBundle {
66    pub proof: Vec<u8>,
67    pub public_inputs: Vec<[u8; 32]>,
68    pub deposit: DepositNoteResult,
69    pub calldata: Bytes,
70}
71
72#[derive(Debug, Clone)]
73pub struct WithdrawProofBundle {
74    pub proof: Vec<u8>,
75    pub public_inputs: Vec<[u8; 32]>,
76    pub nullifier_hash: H256,
77    pub change_note: Option<ChangeNoteResult>,
78    pub calldata: Bytes,
79}
80
81#[derive(Debug, Clone)]
82pub struct TransferProofBundle {
83    pub proof: Vec<u8>,
84    pub public_inputs: Vec<[u8; 32]>,
85    pub nullifier_hash: H256,
86    pub memo_commitment: U256,
87    pub change_commitment: U256,
88    /// For recipient scanning via `NewPrivateMemo` events
89    pub transfer_tag: U256,
90    pub calldata: Bytes,
91}
92
93#[derive(Debug, Clone)]
94pub struct SplitProofBundle {
95    pub proof: Vec<u8>,
96    pub public_inputs: Vec<[u8; 32]>,
97    pub nullifier_hash: H256,
98    pub note_out_1: ChangeNoteResult,
99    pub note_out_2: ChangeNoteResult,
100    pub calldata: Bytes,
101}
102
103#[derive(Debug, Clone)]
104pub struct JoinProofBundle {
105    pub proof: Vec<u8>,
106    pub public_inputs: Vec<[u8; 32]>,
107    pub nullifier_hash_a: H256,
108    pub nullifier_hash_b: H256,
109    pub note_out: ChangeNoteResult,
110    pub calldata: Bytes,
111}
112
113#[derive(Debug, Clone)]
114pub struct PublicClaimProofBundle {
115    pub proof: Vec<u8>,
116    pub public_inputs: Vec<[u8; 32]>,
117    pub note_out: ChangeNoteResult,
118    pub calldata: Bytes,
119}
120
121#[derive(Debug, Clone)]
122pub struct BuilderConfig {
123    pub fee_config: FeeConfig,
124    pub darkpool_address: Address,
125    pub multicall_address: Address,
126    pub compliance_pk: (U256, U256),
127}
128
129impl Default for BuilderConfig {
130    fn default() -> Self {
131        Self {
132            fee_config: FeeConfig::default(),
133            darkpool_address: Address::zero(),
134            multicall_address: Address::zero(),
135            compliance_pk: (U256::zero(), U256::zero()),
136        }
137    }
138}
139
140pub struct TransactionBuilder {
141    prover: Arc<ClientProver>,
142    fee_manager: FeeManager,
143    config: BuilderConfig,
144}
145
146impl TransactionBuilder {
147    #[must_use]
148    pub fn new(prover: Arc<ClientProver>, config: BuilderConfig) -> Self {
149        Self {
150            prover,
151            fee_manager: FeeManager::new(config.fee_config.clone()),
152            config,
153        }
154    }
155
156    /// Build a gas payment proof for a relayed action.
157    ///
158    /// `pre_created_change`: when provided, the scan engine can discover the gas change note.
159    /// `current_timestamp`: Unix seconds; the ZK circuit checks `>= note.timelock`. Pass 0 for no-timelock notes.
160    #[allow(clippy::too_many_arguments)]
161    pub async fn build_gas_payment(
162        &self,
163        note: &WalletNote,
164        merkle_root: U256,
165        merkle_path: Vec<U256>,
166        payment_amount: U256,
167        relayer_address: Address,
168        execution_hash: H256,
169        pre_created_change: Option<ChangeNoteResult>,
170        current_timestamp: u64,
171    ) -> Result<GasPaymentBundle, BuilderError> {
172        info!(
173            "Building gas payment proof: amount={}, note_value={}",
174            payment_amount, note.note.value
175        );
176
177        if note.note.value < payment_amount {
178            return Err(BuilderError::InsufficientFunds {
179                needed: payment_amount,
180                available: note.note.value,
181            });
182        }
183
184        let (change_note, change_ephemeral_sk) = if let Some(change_result) = pre_created_change {
185            (change_result.note, change_result.ephemeral_sk)
186        } else {
187            let change_value = note.note.value.checked_sub(payment_amount).ok_or(
188                BuilderError::InsufficientFunds {
189                    needed: payment_amount,
190                    available: note.note.value,
191                },
192            )?;
193            let asset_address = field_to_address(note.note.asset_id);
194            (
195                NotePlaintext::random(change_value, asset_address),
196                generate_random_scalar(),
197            )
198        };
199
200        let inputs = GasPaymentInputs {
201            merkle_root,
202            current_timestamp,
203            payment_value: payment_amount,
204            payment_asset_id: note.note.asset_id,
205            relayer_address,
206            execution_hash: U256::from_big_endian(execution_hash.as_bytes()),
207            compliance_pk: self.config.compliance_pk,
208            old_note: note.note.clone(),
209            old_shared_secret: note.spending_secret,
210            old_note_index: note.leaf_index,
211            old_note_path: merkle_path,
212            hashlock_preimage: U256::zero(),
213            change_note,
214            change_ephemeral_sk,
215        };
216
217        debug!("Generating gas payment ZK proof...");
218        let proof_data = self
219            .prover
220            .prove_gas_payment(&inputs)
221            .await
222            .map_err(|e| BuilderError::ProofGeneration(e.to_string()))?;
223
224        let public_inputs_bytes = convert_public_inputs_to_bytes32(&proof_data.public_inputs)?;
225        let nullifier_hash = extract_gas_payment_nullifier(&public_inputs_bytes)?;
226
227        info!(
228            "Gas payment proof generated: {} bytes, {} public inputs",
229            proof_data.proof.len(),
230            public_inputs_bytes.len()
231        );
232
233        Ok(GasPaymentBundle {
234            proof: proof_data.proof,
235            public_inputs: public_inputs_bytes,
236            public_inputs_hex: proof_data.public_inputs,
237            nullifier_hash,
238            fee_amount: payment_amount,
239            execution_hash,
240        })
241    }
242
243    /// Build a complete multicall bundle (gas payment proof + action) for relay.
244    #[allow(clippy::too_many_arguments)]
245    pub async fn build_paid_action(
246        &self,
247        note: &WalletNote,
248        merkle_root: U256,
249        merkle_path: Vec<U256>,
250        action_target: Address,
251        action_calldata: Bytes,
252        prices: &PriceData,
253        relayer_address: Address,
254        gas_limit: U256,
255        gas_change_note: Option<ChangeNoteResult>,
256        current_timestamp: u64,
257    ) -> Result<MulticallBundle, BuilderError> {
258        if self.config.multicall_address.is_zero() {
259            return Err(BuilderError::Config(
260                "multicall_address is zero -- BuilderConfig.multicall_address must be set for paid actions".into(),
261            ));
262        }
263
264        let fee_estimate = self.fee_manager.calculate_fee(gas_limit, prices);
265        debug!(
266            "Fee estimate: {} (gas: {}, premium: {}bps)",
267            fee_estimate.fee_amount, fee_estimate.gas_limit, fee_estimate.premium_bps
268        );
269
270        let execution_hash =
271            compute_execution_hash(&action_target, &action_calldata, &fee_estimate.fee_amount);
272
273        let gas_payment = self
274            .build_gas_payment(
275                note,
276                merkle_root,
277                merkle_path,
278                fee_estimate.fee_amount,
279                relayer_address,
280                execution_hash,
281                gas_change_note,
282                current_timestamp,
283            )
284            .await?;
285
286        let multicall_data = encode_multicall(
287            self.config.darkpool_address,
288            &gas_payment.proof,
289            &gas_payment.public_inputs,
290            action_target,
291            action_calldata,
292        )?;
293
294        Ok(MulticallBundle {
295            gas_payment,
296            multicall_data,
297            multicall_target: self.config.multicall_address,
298        })
299    }
300
301    /// Estimate fee for an action without generating proof
302    #[must_use]
303    pub fn estimate_fee(&self, gas_limit: U256, prices: &PriceData) -> FeeEstimate {
304        self.fee_manager.calculate_fee(gas_limit, prices)
305    }
306
307    pub async fn build_deposit(
308        &self,
309        deposit_result: &DepositNoteResult,
310    ) -> Result<DepositProofBundle, BuilderError> {
311        info!(
312            "Building deposit proof: value={}",
313            deposit_result.note.value
314        );
315
316        let inputs = DepositInputs {
317            note_plaintext: deposit_result.note.clone(),
318            ephemeral_sk: deposit_result.ephemeral_sk,
319            compliance_pk: self.config.compliance_pk,
320        };
321
322        let proof_data = self
323            .prover
324            .prove_deposit(&inputs)
325            .await
326            .map_err(|e| BuilderError::ProofGeneration(e.to_string()))?;
327
328        let public_inputs = convert_public_inputs_to_bytes32(&proof_data.public_inputs)?;
329
330        let calldata = encode_deposit_calldata(&proof_data.proof, &public_inputs);
331
332        info!("Deposit proof generated: {} bytes", proof_data.proof.len());
333
334        Ok(DepositProofBundle {
335            proof: proof_data.proof,
336            public_inputs,
337            deposit: deposit_result.clone(),
338            calldata,
339        })
340    }
341
342    #[allow(clippy::too_many_arguments)]
343    pub async fn build_withdraw(
344        &self,
345        spending_inputs: &SpendingInputs,
346        withdraw_value: U256,
347        recipient: Address,
348        merkle_root: U256,
349        change_note: Option<&ChangeNoteResult>,
350        intent_hash: Option<U256>,
351        current_timestamp: u64,
352    ) -> Result<WithdrawProofBundle, BuilderError> {
353        info!(
354            "Building withdraw proof: value={}, recipient={:?}",
355            withdraw_value, recipient
356        );
357
358        let change = if let Some(cn) = change_note {
359            cn.note.clone()
360        } else {
361            NotePlaintext::random(U256::zero(), Address::zero())
362        };
363        let change_sk = change_note.map_or_else(generate_random_scalar, |c| c.ephemeral_sk);
364
365        let inputs = WithdrawInputs {
366            withdraw_value,
367            recipient,
368            merkle_root,
369            current_timestamp,
370            intent_hash: intent_hash.unwrap_or(U256::zero()),
371            compliance_pk: self.config.compliance_pk,
372            old_note: spending_inputs.note.clone(),
373            old_shared_secret: spending_inputs.shared_secret,
374            old_note_index: spending_inputs.leaf_index,
375            old_note_path: spending_inputs.merkle_path.siblings_vec(),
376            hashlock_preimage: spending_inputs.hashlock_preimage,
377            change_note: change,
378            change_ephemeral_sk: change_sk,
379        };
380
381        let proof_data = self
382            .prover
383            .prove_withdraw(&inputs)
384            .await
385            .map_err(|e| BuilderError::ProofGeneration(e.to_string()))?;
386
387        let public_inputs = convert_public_inputs_to_bytes32(&proof_data.public_inputs)?;
388        let nullifier_hash = extract_withdraw_nullifier(&public_inputs)?;
389
390        let calldata = encode_withdraw_calldata(&proof_data.proof, &public_inputs);
391
392        info!("Withdraw proof generated: {} bytes", proof_data.proof.len());
393
394        Ok(WithdrawProofBundle {
395            proof: proof_data.proof,
396            public_inputs,
397            nullifier_hash,
398            change_note: change_note.cloned(),
399            calldata,
400        })
401    }
402
403    #[allow(clippy::too_many_arguments)]
404    pub async fn build_transfer(
405        &self,
406        spending_inputs: &SpendingInputs,
407        merkle_root: U256,
408        recipient_b: (U256, U256),
409        recipient_p: (U256, U256),
410        recipient_proof: DLEQProof,
411        memo_note: NotePlaintext,
412        memo_ephemeral_sk: U256,
413        change_note: NotePlaintext,
414        change_ephemeral_sk: U256,
415        current_timestamp: u64,
416    ) -> Result<TransferProofBundle, BuilderError> {
417        info!("Building transfer proof: memo_value={}", memo_note.value);
418
419        let inputs = TransferInputs {
420            merkle_root,
421            current_timestamp,
422            compliance_pk: self.config.compliance_pk,
423            recipient_b,
424            recipient_p,
425            recipient_proof,
426            old_note: spending_inputs.note.clone(),
427            old_shared_secret: spending_inputs.shared_secret,
428            old_note_index: spending_inputs.leaf_index,
429            old_note_path: spending_inputs.merkle_path.siblings_vec(),
430            hashlock_preimage: spending_inputs.hashlock_preimage,
431            memo_note,
432            memo_ephemeral_sk,
433            change_note,
434            change_ephemeral_sk,
435        };
436
437        let proof_data = self
438            .prover
439            .prove_transfer(&inputs)
440            .await
441            .map_err(|e| BuilderError::ProofGeneration(e.to_string()))?;
442
443        let public_inputs = convert_public_inputs_to_bytes32(&proof_data.public_inputs)?;
444        let nullifier_hash = extract_transfer_nullifier(&public_inputs)?;
445
446        let calldata = encode_transfer_calldata(&proof_data.proof, &public_inputs);
447        let (memo_commitment, change_commitment, transfer_tag) =
448            extract_transfer_commitments(&proof_data.public_inputs)?;
449
450        info!("Transfer proof generated: {} bytes", proof_data.proof.len());
451
452        Ok(TransferProofBundle {
453            proof: proof_data.proof,
454            public_inputs,
455            nullifier_hash,
456            memo_commitment,
457            change_commitment,
458            transfer_tag,
459            calldata,
460        })
461    }
462
463    pub async fn build_split(
464        &self,
465        spending_inputs: &SpendingInputs,
466        merkle_root: U256,
467        note_out_1: &ChangeNoteResult,
468        note_out_2: &ChangeNoteResult,
469        current_timestamp: u64,
470    ) -> Result<SplitProofBundle, BuilderError> {
471        info!(
472            "Building split proof: value_1={}, value_2={}",
473            note_out_1.note.value, note_out_2.note.value
474        );
475
476        let inputs = SplitInputs {
477            merkle_root,
478            current_timestamp,
479            compliance_pk: self.config.compliance_pk,
480            note_in: spending_inputs.note.clone(),
481            secret_in: spending_inputs.shared_secret,
482            index_in: spending_inputs.leaf_index,
483            path_in: spending_inputs.merkle_path.siblings_vec(),
484            preimage_in: spending_inputs.hashlock_preimage,
485            note_out_1: note_out_1.note.clone(),
486            sk_out_1: note_out_1.ephemeral_sk,
487            note_out_2: note_out_2.note.clone(),
488            sk_out_2: note_out_2.ephemeral_sk,
489        };
490
491        let proof_data = self
492            .prover
493            .prove_split(&inputs)
494            .await
495            .map_err(|e| BuilderError::ProofGeneration(e.to_string()))?;
496
497        let public_inputs = convert_public_inputs_to_bytes32(&proof_data.public_inputs)?;
498        let nullifier_hash = extract_split_nullifier(&public_inputs)?;
499
500        let calldata = encode_split_calldata(&proof_data.proof, &public_inputs);
501
502        info!("Split proof generated: {} bytes", proof_data.proof.len());
503
504        Ok(SplitProofBundle {
505            proof: proof_data.proof,
506            public_inputs,
507            nullifier_hash,
508            note_out_1: note_out_1.clone(),
509            note_out_2: note_out_2.clone(),
510            calldata,
511        })
512    }
513
514    pub async fn build_join(
515        &self,
516        inputs_a: &SpendingInputs,
517        inputs_b: &SpendingInputs,
518        merkle_root: U256,
519        note_out: &ChangeNoteResult,
520        current_timestamp: u64,
521    ) -> Result<JoinProofBundle, BuilderError> {
522        info!(
523            "Building join proof: value_a={} + value_b={} = {}",
524            inputs_a.note.value, inputs_b.note.value, note_out.note.value
525        );
526
527        let inputs = JoinInputs {
528            merkle_root,
529            current_timestamp,
530            compliance_pk: self.config.compliance_pk,
531            note_a: inputs_a.note.clone(),
532            secret_a: inputs_a.shared_secret,
533            index_a: inputs_a.leaf_index,
534            path_a: inputs_a.merkle_path.siblings_vec(),
535            preimage_a: inputs_a.hashlock_preimage,
536            note_b: inputs_b.note.clone(),
537            secret_b: inputs_b.shared_secret,
538            index_b: inputs_b.leaf_index,
539            path_b: inputs_b.merkle_path.siblings_vec(),
540            preimage_b: inputs_b.hashlock_preimage,
541            note_out: note_out.note.clone(),
542            sk_out: note_out.ephemeral_sk,
543        };
544
545        let proof_data = self
546            .prover
547            .prove_join(&inputs)
548            .await
549            .map_err(|e| BuilderError::ProofGeneration(e.to_string()))?;
550
551        let public_inputs = convert_public_inputs_to_bytes32(&proof_data.public_inputs)?;
552
553        let (nullifier_hash_a, nullifier_hash_b) = extract_join_nullifiers(&public_inputs)?;
554
555        let calldata = encode_join_calldata(&proof_data.proof, &public_inputs);
556
557        info!("Join proof generated: {} bytes", proof_data.proof.len());
558
559        Ok(JoinProofBundle {
560            proof: proof_data.proof,
561            public_inputs,
562            nullifier_hash_a,
563            nullifier_hash_b,
564            note_out: note_out.clone(),
565            calldata,
566        })
567    }
568
569    #[allow(clippy::too_many_arguments)]
570    pub async fn build_public_claim(
571        &self,
572        memo_id: U256,
573        val: U256,
574        asset_id: U256,
575        timelock: U256,
576        owner_pk: (U256, U256),
577        salt: U256,
578        recipient_sk: U256,
579        note_out: &ChangeNoteResult,
580    ) -> Result<PublicClaimProofBundle, BuilderError> {
581        info!("Building public claim proof: memo_id={}", memo_id);
582
583        let inputs = PublicClaimInputs {
584            memo_id,
585            compliance_pk: self.config.compliance_pk,
586            val,
587            asset_id,
588            timelock,
589            owner_x: owner_pk.0,
590            owner_y: owner_pk.1,
591            salt,
592            recipient_sk,
593            note_out: note_out.note.clone(),
594            sk_out: note_out.ephemeral_sk,
595        };
596
597        let proof_data = self
598            .prover
599            .prove_public_claim(&inputs)
600            .await
601            .map_err(|e| BuilderError::ProofGeneration(e.to_string()))?;
602
603        let public_inputs = convert_public_inputs_to_bytes32(&proof_data.public_inputs)?;
604        let calldata = encode_public_claim_calldata(&proof_data.proof, &public_inputs);
605
606        info!(
607            "Public claim proof generated: {} bytes",
608            proof_data.proof.len()
609        );
610
611        Ok(PublicClaimProofBundle {
612            proof: proof_data.proof,
613            public_inputs,
614            note_out: note_out.clone(),
615            calldata,
616        })
617    }
618}
619
620/// Convert hex string public inputs to bytes32 arrays
621pub fn convert_public_inputs_to_bytes32(inputs: &[String]) -> Result<Vec<[u8; 32]>, BuilderError> {
622    inputs
623        .iter()
624        .map(|input| {
625            let hex_str = input.trim_start_matches("0x");
626            let bytes = hex::decode(hex_str)
627                .map_err(|e| BuilderError::Encoding(format!("Invalid hex: {e}")))?;
628
629            if bytes.len() != 32 {
630                return Err(BuilderError::Encoding(format!(
631                    "Public input must be 32 bytes, got {}",
632                    bytes.len()
633                )));
634            }
635
636            let mut arr = [0u8; 32];
637            arr.copy_from_slice(&bytes);
638            Ok(arr)
639        })
640        .collect()
641}
642
643/// Compute execution hash binding an action to a gas payment.
644/// Result is reduced mod BN254 Fr so it's a valid ZK circuit field element.
645pub fn compute_execution_hash(target: &Address, calldata: &Bytes, fee: &U256) -> H256 {
646    use ethers::utils::keccak256;
647
648    let mut data = Vec::new();
649    data.extend_from_slice(target.as_bytes());
650    data.extend_from_slice(calldata.as_ref());
651    let mut fee_bytes = [0u8; 32];
652    fee.to_big_endian(&mut fee_bytes);
653    data.extend_from_slice(&fee_bytes);
654
655    let hash = keccak256(&data);
656
657    let hash_u256 = U256::from_big_endian(&hash);
658    let reduced = hash_u256 % *BN254_MODULUS;
659
660    let mut result_bytes = [0u8; 32];
661    reduced.to_big_endian(&mut result_bytes);
662    H256::from_slice(&result_bytes)
663}
664
665/// Reduces mod BJJ subgroup order L (~2^251), NOT BN254 Fr (~2^254).
666/// Noir's `ScalarField::<63>` nibble decomposition requires values < 2^252.
667fn generate_random_scalar() -> U256 {
668    darkpool_crypto::random_bjj_scalar()
669}
670
671/// Encode a multicall bundle for the `RelayerMulticall` contract
672pub fn encode_multicall(
673    darkpool: Address,
674    proof: &[u8],
675    public_inputs: &[[u8; 32]],
676    action_target: Address,
677    action_calldata: Bytes,
678) -> Result<Bytes, BuilderError> {
679    // payRelayer selector: keccak256("payRelayer(bytes,bytes32[])")[:4]
680    let proof_token = Token::Bytes(proof.to_vec());
681    let inputs_token = Token::Array(
682        public_inputs
683            .iter()
684            .map(|b| Token::FixedBytes(b.to_vec()))
685            .collect(),
686    );
687
688    let pay_relayer_selector: [u8; 4] = [0x24, 0xfc, 0xf1, 0x31];
689    let mut darkpool_calldata = pay_relayer_selector.to_vec();
690    darkpool_calldata.extend(encode(&[proof_token, inputs_token]));
691
692    let calls = vec![
693        Token::Tuple(vec![
694            Token::Address(darkpool),
695            Token::Bytes(darkpool_calldata),
696            Token::Uint(U256::zero()),
697            Token::Bool(true),
698        ]),
699        Token::Tuple(vec![
700            Token::Address(action_target),
701            Token::Bytes(action_calldata.to_vec()),
702            Token::Uint(U256::zero()),
703            Token::Bool(true),
704        ]),
705    ];
706
707    // multicall selector: 0xcffb5cd6
708    let mut encoded = vec![0xcf, 0xfb, 0x5c, 0xd6];
709    encoded.extend(encode(&[Token::Array(calls)]));
710
711    Ok(Bytes::from(encoded))
712}
713
714/// No-op for `UltraHonk` proofs -- BB already outputs the correct format.
715#[must_use]
716pub fn format_proof_for_solidity(proof: &[u8]) -> Bytes {
717    Bytes::from(proof.to_vec())
718}
719
720/// Alias for `convert_public_inputs_to_bytes32`
721pub fn format_public_inputs_for_solidity(inputs: &[String]) -> Result<Vec<[u8; 32]>, BuilderError> {
722    convert_public_inputs_to_bytes32(inputs)
723}
724
725/// Withdraw circuit: `nullifier_hash` at index 7.
726fn extract_withdraw_nullifier(public_inputs: &[[u8; 32]]) -> Result<H256, BuilderError> {
727    public_inputs
728        .get(7)
729        .map(|b| H256::from_slice(b))
730        .ok_or_else(|| {
731            BuilderError::Encoding(format!(
732                "Withdraw public inputs too short: expected index 7, got length {}",
733                public_inputs.len()
734            ))
735        })
736}
737
738/// Transfer circuit: `nullifier_hash` at index 8.
739fn extract_transfer_nullifier(public_inputs: &[[u8; 32]]) -> Result<H256, BuilderError> {
740    public_inputs
741        .get(8)
742        .map(|b| H256::from_slice(b))
743        .ok_or_else(|| {
744            BuilderError::Encoding(format!(
745                "Transfer public inputs too short: expected index 8, got length {}",
746                public_inputs.len()
747            ))
748        })
749}
750
751/// Join circuit: `nullifier_hash_a` at index 4, `nullifier_hash_b` at index 5.
752fn extract_join_nullifiers(public_inputs: &[[u8; 32]]) -> Result<(H256, H256), BuilderError> {
753    let a = public_inputs
754        .get(4)
755        .map(|b| H256::from_slice(b))
756        .ok_or_else(|| {
757            BuilderError::Encoding(format!(
758                "Join public inputs too short: expected index 4, got length {}",
759                public_inputs.len()
760            ))
761        })?;
762    let b = public_inputs
763        .get(5)
764        .map(|b| H256::from_slice(b))
765        .ok_or_else(|| {
766            BuilderError::Encoding(format!(
767                "Join public inputs too short: expected index 5, got length {}",
768                public_inputs.len()
769            ))
770        })?;
771    Ok((a, b))
772}
773
774/// Split circuit: `nullifier_hash` at index 4.
775fn extract_split_nullifier(public_inputs: &[[u8; 32]]) -> Result<H256, BuilderError> {
776    public_inputs
777        .get(4)
778        .map(|b| H256::from_slice(b))
779        .ok_or_else(|| {
780            BuilderError::Encoding(format!(
781                "Split public inputs too short: expected index 4, got length {}",
782                public_inputs.len()
783            ))
784        })
785}
786
787/// Gas payment circuit: `nullifier_hash` at index 8.
788fn extract_gas_payment_nullifier(public_inputs: &[[u8; 32]]) -> Result<H256, BuilderError> {
789    public_inputs
790        .get(8)
791        .map(|b| H256::from_slice(b))
792        .ok_or_else(|| {
793            BuilderError::Encoding(format!(
794                "Gas payment public inputs too short: expected index 8, got length {}",
795                public_inputs.len()
796            ))
797        })
798}
799
800/// Extract commitments from transfer circuit public inputs.
801///
802/// Layout: `[11-17]` `memo_packed_ct`, `[18]` `int_bob.x` (= `transfer_tag`), `[24-30]` `change_packed_ct`.
803/// Commitments are Poseidon hashes of the packed ciphertexts.
804fn extract_transfer_commitments(
805    public_inputs_hex: &[String],
806) -> Result<(U256, U256, U256), BuilderError> {
807    use crate::crypto_helpers::poseidon_hash;
808
809    if public_inputs_hex.len() < 31 {
810        return Err(BuilderError::Encoding(format!(
811            "Transfer proof needs at least 31 public inputs, got {}",
812            public_inputs_hex.len()
813        )));
814    }
815
816    let parse_hex = |idx: usize| -> Result<U256, BuilderError> {
817        let hex_str = public_inputs_hex.get(idx).ok_or_else(|| {
818            BuilderError::Encoding(format!("Missing public input at index {idx}"))
819        })?;
820        let clean = hex_str.trim_start_matches("0x");
821        let padded = format!("{clean:0>64}");
822        let bytes = hex::decode(&padded)
823            .map_err(|e| BuilderError::Encoding(format!("Invalid hex at {idx}: {e}")))?;
824        Ok(U256::from_big_endian(&bytes))
825    };
826
827    let memo_packed: Vec<U256> = (11..=17).map(parse_hex).collect::<Result<_, _>>()?;
828    let change_packed: Vec<U256> = (24..=30).map(parse_hex).collect::<Result<_, _>>()?;
829
830    let memo_commitment = poseidon_hash(&memo_packed);
831    let change_commitment = poseidon_hash(&change_packed);
832    let transfer_tag = parse_hex(18)?;
833
834    Ok((memo_commitment, change_commitment, transfer_tag))
835}
836
837/// Encode ABI calldata for a `DarkPool` proof-verified function.
838/// All share the shape: `function(bytes _proof, bytes32[] _publicInputs)`.
839fn encode_calldata(fn_sig: &[u8], proof: &[u8], public_inputs: &[[u8; 32]]) -> Bytes {
840    use ethers::utils::keccak256;
841
842    let hash = keccak256(fn_sig);
843    let selector = [hash[0], hash[1], hash[2], hash[3]];
844
845    let proof_token = Token::Bytes(proof.to_vec());
846    let inputs_token = Token::Array(
847        public_inputs
848            .iter()
849            .map(|b| Token::FixedBytes(b.to_vec()))
850            .collect(),
851    );
852
853    let mut calldata = selector.to_vec();
854    calldata.extend(encode(&[proof_token, inputs_token]));
855
856    Bytes::from(calldata)
857}
858
859fn encode_deposit_calldata(proof: &[u8], public_inputs: &[[u8; 32]]) -> Bytes {
860    encode_calldata(b"deposit(bytes,bytes32[])", proof, public_inputs)
861}
862
863fn encode_withdraw_calldata(proof: &[u8], public_inputs: &[[u8; 32]]) -> Bytes {
864    encode_calldata(b"withdraw(bytes,bytes32[])", proof, public_inputs)
865}
866
867fn encode_transfer_calldata(proof: &[u8], public_inputs: &[[u8; 32]]) -> Bytes {
868    encode_calldata(b"privateTransfer(bytes,bytes32[])", proof, public_inputs)
869}
870
871fn encode_split_calldata(proof: &[u8], public_inputs: &[[u8; 32]]) -> Bytes {
872    encode_calldata(b"split(bytes,bytes32[])", proof, public_inputs)
873}
874
875fn encode_join_calldata(proof: &[u8], public_inputs: &[[u8; 32]]) -> Bytes {
876    encode_calldata(b"join(bytes,bytes32[])", proof, public_inputs)
877}
878
879fn encode_public_claim_calldata(proof: &[u8], public_inputs: &[[u8; 32]]) -> Bytes {
880    encode_calldata(b"publicClaim(bytes,bytes32[])", proof, public_inputs)
881}
882
883#[cfg(test)]
884mod tests {
885    use super::*;
886
887    #[test]
888    fn test_execution_hash() {
889        let target = Address::zero();
890        let calldata = Bytes::from(vec![1, 2, 3, 4]);
891        let fee = U256::from(1000);
892
893        let hash = compute_execution_hash(&target, &calldata, &fee);
894
895        let hash2 = compute_execution_hash(&target, &calldata, &fee);
896        assert_eq!(hash, hash2);
897
898        let hash3 = compute_execution_hash(&target, &calldata, &U256::from(1001));
899        assert_ne!(hash, hash3);
900    }
901
902    /// ~75% of raw keccak256 outputs overflow BN254 Fr; verify reduction is always applied.
903    #[test]
904    fn test_keccak256_bn254_reduction() {
905        let bn254_modulus = *BN254_MODULUS;
906
907        for i in 0u64..50 {
908            let target = Address::from_slice(&{
909                let mut b = [0u8; 20];
910                b[..8].copy_from_slice(&i.to_le_bytes());
911                b
912            });
913            let calldata = Bytes::from(i.to_le_bytes().to_vec());
914            let fee = U256::from(i * 1_000_000u64);
915
916            let hash = compute_execution_hash(&target, &calldata, &fee);
917            let hash_as_u256 = U256::from_big_endian(hash.as_bytes());
918
919            assert!(
920                hash_as_u256 < bn254_modulus,
921                "compute_execution_hash output {hash_as_u256} >= BN254_MODULUS for input i={i}"
922            );
923        }
924    }
925
926    #[test]
927    fn test_convert_public_inputs() {
928        let inputs = vec![
929            "0x0000000000000000000000000000000000000000000000000000000000000001".to_string(),
930            "0x0000000000000000000000000000000000000000000000000000000000000002".to_string(),
931        ];
932
933        let result = convert_public_inputs_to_bytes32(&inputs).unwrap();
934
935        assert_eq!(result.len(), 2);
936        assert_eq!(result[0][31], 1);
937        assert_eq!(result[1][31], 2);
938    }
939
940    #[test]
941    fn test_encode_multicall() {
942        let darkpool = Address::random();
943        let proof = vec![0u8; 100];
944        let public_inputs = vec![[0u8; 32]; 5];
945        let action_target = Address::random();
946        let action_calldata = Bytes::from(vec![1, 2, 3, 4]);
947
948        let result = encode_multicall(
949            darkpool,
950            &proof,
951            &public_inputs,
952            action_target,
953            action_calldata,
954        );
955
956        assert!(result.is_ok());
957        let encoded = result.unwrap();
958
959        assert_eq!(&encoded[0..4], &[0xcf, 0xfb, 0x5c, 0xd6]);
960    }
961}