Skip to main content

privacy_core/ethereum/
mod.rs

1//! Calldata encoding for `PrivacyBTC.sol` — all operations go through `bundle()`.
2//!
3//! valueBalance encoding (sign-bit / readable int64 convention, mirrors Zcash §7.1):
4//!   0                     → transfer  (Σv_old == Σv_new)
5//!   +amount  (bit255 = 0) → unshield  (value leaving pool, positive satoshis)
6//!   -amount  (bit255 = 1) → shield    (value entering pool, high bit = sign)
7//!
8//! The raw satoshi count is always stored in the lower 64 bits; bit 255 is the sign.
9//! When doing BJJ cryptography (BindingSig), the contract/prover decodes the sign
10//! and computes the actual scalar: positive → scalar, negative → ℓ − scalar.
11
12pub mod groth16_proof;
13pub use groth16_proof::{
14    encode_groth16_proof_components, encode_groth16_proof_from_snarkjs_json,
15    p_a_from_snarkjs_pi_a, p_b_from_snarkjs_pi_b, p_c_from_snarkjs_pi_c, Groth16ProofError,
16};
17
18mod bundle_decode;
19mod events;
20mod perc20;
21pub use bundle_decode::{
22    bundle_actions_by_cmx, decode_bundle_calldata, BundleActionCiphertexts, BundleDecodeError,
23};
24pub use events::{
25    decode_note_added_log, decode_note_confirmed_log, decode_shield_completed_log,
26    decode_shielded_log, decode_shield_pool_created_log, decode_unshielded_log,
27    note_added_legacy_topic0_hex, note_added_topic0_alternatives, note_added_topic0_hex,
28    note_confirmed_topic0_hex, perc20_created_topic0_hex, shield_completed_topic0_hex,
29    shield_pool_created_topic0_hex, shield_pool_deployed_topic0_hex, shielded_topic0_hex,
30    unshielded_topic0_hex, DecodedNoteAdded, DecodedShielded,
31    DecodedShieldPoolCreated, LogDecodeError,
32};
33pub use perc20::{
34    compute_swap_id, encode_perc20_transfer_calldata, encode_perc20_transfer_executor_calldata,
35    encode_swap_initiate_calldata, encode_swap_join_calldata, encode_swap_settle_calldata,
36    encode_wrapped_shield_calldata, encode_wrapped_unshield_calldata, perc20_transfer_executor_selector,
37    perc20_transfer_selector, privacy_call_commit, swap_initiate_selector, swap_join_selector,
38    swap_settle_selector, wrapped_shield_selector, wrapped_unshield_selector, PrivacyCallArgs,
39};
40
41use ethabi::{encode, Token, Uint};
42use sha3::{Digest, Keccak256};
43use thiserror::Error;
44
45// ── Error ────────────────────────────────────────────────────────────────────
46
47#[derive(Debug, Error)]
48pub enum EthEncodeError {
49    #[error("enc_ciphertext must be 580 bytes (Orchard in-band), got {0}")]
50    BadEncLen(usize),
51}
52
53// ── bundle(BundleAction[],uint256,uint256,bytes32,uint256[3]) ─────────────────
54
55/// One action passed to `bundle()`.
56#[derive(Debug, Clone)]
57pub struct BundleActionArgs {
58    pub cmx: [u8; 32],
59    pub enc_ciphertext: Vec<u8>,
60    pub out_ciphertext: Vec<u8>,
61    pub epk: [u8; 32],
62    pub nf_old: [u8; 32],
63    pub anchor: [u8; 32],
64    pub proof: Vec<u8>,
65    /// 8 BN254 calldata fields: [anchor, cv_x, cv_y, nf, rk_x, rk_y, cmx, rt_frozen].
66    pub pub_fields: [[u8; 32]; 8],
67    pub spend_auth_sig: [[u8; 32]; 3],
68}
69
70/// Arguments for
71/// `bundle(BundleAction[],uint256,uint256,bytes32,uint256[3])`.
72#[derive(Debug, Clone)]
73pub struct BundleCalldataArgs {
74    pub actions: Vec<BundleActionArgs>,
75    /// Net value balance (Fr element BE); 0x00 for pure transfer, +v for unshield, Fr−v for shield.
76    pub value_balance: [u8; 32],
77    /// Absolute satoshi amount for unshield; 0 for transfer.
78    pub amount: u64,
79    /// BTC recipient hash for unshield; [0u8; 32] for transfer.
80    pub recipient_meta: [u8; 32],
81    /// Baby JubJub Schnorr binding signature [Rx, Ry, s]; bsk = Σ rcv_i.
82    pub binding_sig: [[u8; 32]; 3],
83}
84
85/// First 4 bytes of
86/// `keccak256("bundle((bytes32,bytes,bytes,bytes32,bytes32,bytes32,bytes,uint256[8],uint256[3])[],…")`.
87pub fn bundle_function_selector() -> [u8; 4] {
88    Keccak256::digest(
89        b"bundle((bytes32,bytes,bytes,bytes32,bytes32,bytes32,bytes,uint256[8],uint256[3])[],uint256,uint256,bytes32,uint256[3])",
90    )[..4]
91    .try_into()
92    .expect("selector is 4 bytes")
93}
94
95/// ABI-encode `bundle` calldata (selector + body).
96pub fn encode_bundle_calldata(args: &BundleCalldataArgs) -> Result<Vec<u8>, EthEncodeError> {
97    let actions_token = Token::Array(
98        args.actions
99            .iter()
100            .map(|a| {
101                let pub_fields_token = Token::FixedArray(
102                    a.pub_fields
103                        .iter()
104                        .map(|b| Token::Uint(ethabi::Uint::from_big_endian(b)))
105                        .collect(),
106                );
107                let spend_auth_sig_token = Token::FixedArray(
108                    a.spend_auth_sig
109                        .iter()
110                        .map(|b| Token::Uint(ethabi::Uint::from_big_endian(b)))
111                        .collect(),
112                );
113                Token::Tuple(vec![
114                    Token::FixedBytes(a.cmx.to_vec()),
115                    Token::Bytes(a.enc_ciphertext.clone()),
116                    Token::Bytes(a.out_ciphertext.clone()),
117                    Token::FixedBytes(a.epk.to_vec()),
118                    Token::FixedBytes(a.nf_old.to_vec()),
119                    Token::FixedBytes(a.anchor.to_vec()),
120                    Token::Bytes(a.proof.clone()),
121                    pub_fields_token,
122                    spend_auth_sig_token,
123                ])
124            })
125            .collect(),
126    );
127    let binding_sig_token = Token::FixedArray(
128        args.binding_sig
129            .iter()
130            .map(|b| Token::Uint(ethabi::Uint::from_big_endian(b)))
131            .collect(),
132    );
133    let tokens = vec![
134        actions_token,
135        Token::Uint(ethabi::Uint::from_big_endian(&args.value_balance)),
136        Token::Uint(ethabi::Uint::from(args.amount)),
137        Token::FixedBytes(args.recipient_meta.to_vec()),
138        binding_sig_token,
139    ];
140    let body = encode(&tokens);
141    let mut out = Vec::with_capacity(4 + body.len());
142    out.extend_from_slice(&bundle_function_selector());
143    out.extend_from_slice(&body);
144    Ok(out)
145}
146
147// ── Baby Jubjub binding Schnorr (matches `BindingSignature.verify`) ──────────
148
149/// EIP-2494 prime subgroup order ℓ (`curve_order = 8·ℓ`; differs from BN254 field modulus).
150pub const BJJ_SUBGROUP_ORDER_DEC: &str =
151    "2736030358979909402780800718157159386076813972158567259200215660948447373041";
152
153#[inline]
154fn bjj_subgroup_order() -> Uint {
155    Uint::from_dec_str(BJJ_SUBGROUP_ORDER_DEC).expect("valid EIP-2494 subgroup constant")
156}
157
158/// Interpret digest / coefficient integer modulo ℓ (challenge scalar).
159#[inline]
160pub fn reduce_mod_bjj_subgroup(v: Uint) -> Uint {
161    let l = bjj_subgroup_order();
162    v % l
163}
164
165/// Fiat–Shamir challenge `e = keccak256(R ‖ bvk ‖ sighash) mod ℓ`.
166pub fn binding_challenge_e_bn254(
167    r_x_be32: &[u8; 32],
168    r_y_be32: &[u8; 32],
169    bvk_x_be32: &[u8; 32],
170    bvk_y_be32: &[u8; 32],
171    sighash: &[u8; 32],
172) -> Uint {
173    let mut h = Keccak256::new();
174    h.update(r_x_be32);
175    h.update(r_y_be32);
176    h.update(bvk_x_be32);
177    h.update(bvk_y_be32);
178    h.update(sighash);
179    let digest: [u8; 32] = h.finalize().into();
180    reduce_mod_bjj_subgroup(Uint::from_big_endian(&digest))
181}
182
183/// (a * b) mod m using binary doubling — avoids U256 overflow for ~251-bit inputs.
184fn mulmod(mut a: Uint, mut b: Uint, m: Uint) -> Uint {
185    let mut result = Uint::zero();
186    a %= m;
187    while !b.is_zero() {
188        if b.bit(0) {
189            result = (result + a) % m;
190        }
191        a = (a + a) % m;
192        b >>= 1;
193    }
194    result
195}
196
197/// Schnorr scalar `s = (r + e·bsk) mod ℓ`.
198#[inline]
199pub fn binding_s_scalar_bn254(r_nonce: Uint, e: Uint, bsk: Uint) -> Uint {
200    let l = bjj_subgroup_order();
201    let r = r_nonce % l;
202    let e = e % l;
203    let bsk = bsk % l;
204    let prod = mulmod(e, bsk, l);
205    (r + prod) % l
206}
207
208#[inline]
209pub fn uint_to_be32(u: &Uint) -> [u8; 32] {
210    let mut out = [0u8; 32];
211    u.to_big_endian(&mut out);
212    out
213}
214
215/// Circom / snarkjs decimal field element → 32-byte big-endian for ABI `uint256`.
216pub fn circom_field_dec_to_be32(dec: &str) -> [u8; 32] {
217    uint_to_be32(
218        &Uint::from_dec_str(dec.trim()).expect("circom decimal field element"),
219    )
220}
221
222/// BN254 `Fr` limb repr (little-endian per `ff`) → big-endian uint, reduced mod ℓ.
223#[inline]
224pub fn uint_mod_subgroup_from_bn254_fr_repr_le(repr_le: &[u8; 32]) -> Uint {
225    let mut be = [0u8; 32];
226    for i in 0..32 {
227        be[i] = repr_le[31 - i];
228    }
229    reduce_mod_bjj_subgroup(Uint::from_big_endian(&be))
230}
231
232/// Sum multiple rcv scalars modulo ℓ (BJJ subgroup order) and return the result
233/// as a canonical little-endian `[u8; 32]` suitable for `Fr::from_repr`.
234///
235/// Using BN254 Fr addition (`mod r`) for this sum is WRONG because `ℓ ≪ r`:
236/// when `rcv0 + rcv1 ≥ r`, Fr wraps modulo `r`, but the BJJ group reduces mod ℓ,
237/// so `bsk_fr mod ℓ ≠ Σ rcv_i mod ℓ`, breaking `bvk = bsk · G_RANDOM`.
238pub fn sum_rcv_mod_bjj(rcv_le_slices: &[[u8; 32]]) -> [u8; 32] {
239    let l = bjj_subgroup_order();
240    let mut acc = Uint::from(0u64);
241    for rcv in rcv_le_slices {
242        let u = uint_mod_subgroup_from_bn254_fr_repr_le(rcv);
243        // Use checked add + manual mod to avoid overflow of U256 on adversarial input.
244        acc = (acc + u) % l;
245    }
246    let be = uint_to_be32(&acc);
247    let mut le = [0u8; 32];
248    for i in 0..32 {
249        le[i] = be[31 - i];
250    }
251    le
252}
253
254// ── BindingSignature.buildSighash (Solidity `abi.encodePacked` mirror) ───────
255
256/// BN254 scalar field modulus as big-endian `uint256` (matches `BabyJubJub.Fr`).
257pub const BN254_FR_BE: [u8; 32] = [
258    0x30, 0x64, 0x4e, 0x72, 0xe1, 0x31, 0xa0, 0x29, 0xb8, 0x50, 0x45, 0xb6, 0x81, 0x81, 0x58,
259    0x5d, 0x97, 0x81, 0x6a, 0x91, 0x68, 0x71, 0xca, 0x8d, 0x3c, 0x20, 0x8c, 0x16, 0xd8, 0x7c,
260    0xfd, 0x47,
261];
262
263#[derive(Debug, Error)]
264pub enum BindingSighashError {
265    #[error("pool_address must be 20-byte hex (40 hex chars), got len {0}")]
266    BadPoolAddress(usize),
267    #[error("invalid hex: {0}")]
268    Hex(String),
269}
270
271/// Encode `chainId` as 32-byte big-endian `uint256` for packed sighash preimage.
272pub fn u256_be_chain_id(chain_id: u64) -> [u8; 32] {
273    let mut out = [0u8; 32];
274    out[24..32].copy_from_slice(&chain_id.to_be_bytes());
275    out
276}
277
278/// Unified `valueBalance` encoder (sign-bit convention, mirrors Zcash protocol §7.1).
279///
280/// Encoding:
281///   transfer:  `[0u8; 32]`                      → 0 (balanced)
282///   unshield:  `bundle_value_balance_be(v, false)` → +v (bit255=0, positive satoshis)
283///   shield:    `bundle_value_balance_be(v, true)`  → high-bit flag + v (bit255=1, negative)
284///
285/// The BindingSignature contract (and prover) decode the sign bit to get the BJJ
286/// scalar for bvk computation:
287///   bit255=0  → scalar = amount_sats  (unshield: bvk = Σcv − amount·G_VALUE)
288///   bit255=1  → scalar = ℓ − amount   (shield:   bvk = Σcv + amount·G_VALUE)
289pub fn bundle_value_balance_be(amount_sats: u64, negative: bool) -> [u8; 32] {
290    let mut out = [0u8; 32];
291    out[24..32].copy_from_slice(&amount_sats.to_be_bytes());
292    if negative {
293        out[0] |= 0x80; // set bit 255 as sign flag
294    }
295    out
296}
297
298/// Decode a sign-bit-encoded `valueBalance` into `(abs_amount_sats, is_negative)`.
299pub fn decode_value_balance_be(vb: &[u8; 32]) -> (u64, bool) {
300    let negative = (vb[0] & 0x80) != 0;
301    let amount = u64::from_be_bytes(vb[24..32].try_into().unwrap());
302    (amount, negative)
303}
304
305/// Decode a sign-bit-encoded `valueBalance` into the actual BJJ subgroup scalar
306/// suitable for `scalarMul(G_VALUE, scalar)` in BindingSignature verification.
307///
308///   bit255=0 (positive, unshield): scalar = amount_sats
309///   bit255=1 (negative, shield):   scalar = ℓ − amount_sats  (additive inverse mod ℓ)
310pub fn value_balance_to_bjj_scalar_be(vb: &[u8; 32]) -> [u8; 32] {
311    let (amount, negative) = decode_value_balance_be(vb);
312    if !negative || amount == 0 {
313        let mut out = [0u8; 32];
314        out[24..32].copy_from_slice(&amount.to_be_bytes());
315        out
316    } else {
317        // ℓ − amount  (BJJ subgroup order minus abs value)
318        let l = bjj_subgroup_order();
319        let amt: Uint = amount.into();
320        uint_to_be32(&(l - amt % l))
321    }
322}
323
324// ── Deprecated helpers kept for reference ─────────────────────────────────────
325
326/// Deprecated: use `bundle_value_balance_be(amount, false)` instead.
327#[deprecated(note = "use bundle_value_balance_be(amount, false)")]
328pub fn shield_bundle_value_balance_be(amount_sats: u64) -> [u8; 32] {
329    bundle_value_balance_be(amount_sats, false)
330}
331
332/// Deprecated: use `bundle_value_balance_be(amount, true)` instead.
333#[deprecated(note = "use bundle_value_balance_be(amount, true)")]
334pub fn shield_bundle_value_balance_subgroup_neg_be(amount_sats: u64) -> [u8; 32] {
335    bundle_value_balance_be(amount_sats, true)
336}
337
338/// Parse `0x`-prefixed 20-byte contract address.
339pub fn parse_pool_address_hex(addr: &str) -> Result<[u8; 20], BindingSighashError> {
340    let clean = addr.strip_prefix("0x").unwrap_or(addr);
341    let bytes = hex::decode(clean).map_err(|e| BindingSighashError::Hex(e.to_string()))?;
342    if bytes.len() != 20 {
343        return Err(BindingSighashError::BadPoolAddress(bytes.len()));
344    }
345    Ok(bytes.try_into().unwrap())
346}
347
348/// Parse `0x`-prefixed 32-byte hash / field element (canonical BE layout).
349pub fn parse_bytes32_hex(s: &str) -> Result<[u8; 32], BindingSighashError> {
350    let clean = s.strip_prefix("0x").unwrap_or(s);
351    let bytes = hex::decode(clean).map_err(|e| BindingSighashError::Hex(e.to_string()))?;
352    if bytes.len() != 32 {
353        return Err(BindingSighashError::Hex(format!(
354            "expected 32 bytes, got {}",
355            bytes.len()
356        )));
357    }
358    Ok(bytes.try_into().unwrap())
359}
360
361/// Keccak256 preimage matching `BindingSignature.buildSighash`:
362/// `abi.encodePacked("PrivacyPool.bundle.v1", chainId, pool, nullifiers[], commitments[], valueBalance, recipientMeta)`.
363pub fn binding_sighash_privacy_pool_bundle_v1(
364    chain_id_be32: &[u8; 32],
365    contract_addr_20: &[u8; 20],
366    nullifiers: &[[u8; 32]],
367    commitments: &[[u8; 32]],
368    value_balance_be32: &[u8; 32],
369    recipient_meta: &[u8; 32],
370) -> [u8; 32] {
371    let mut h = Keccak256::new();
372    h.update(b"PrivacyPool.bundle.v1");
373    h.update(chain_id_be32);
374    h.update(contract_addr_20);
375    for nf in nullifiers {
376        h.update(nf);
377    }
378    for cm in commitments {
379        h.update(cm);
380    }
381    h.update(value_balance_be32);
382    h.update(recipient_meta);
383    h.finalize().into()
384}
385
386/// Keccak256 preimage matching `SpendAuthSignature.buildSighash`:
387/// `abi.encodePacked("SpendAuth.action.v1", chainId, contractAddr,
388///                   nfOld, cmx, epk, keccak256(encCiphertext), keccak256(outCiphertext))`.
389///
390/// Mirrors the Solidity `SpendAuthSignature.buildSighash` exactly.
391pub fn spend_auth_sighash_v1(
392    chain_id_be32: &[u8; 32],
393    contract_addr_20: &[u8; 20],
394    nf_old_be32: &[u8; 32],
395    cmx_be32: &[u8; 32],
396    epk_be32: &[u8; 32],
397    enc_ciphertext: &[u8],
398    out_ciphertext: &[u8],
399) -> [u8; 32] {
400    let enc_hash: [u8; 32] = Keccak256::digest(enc_ciphertext).into();
401    let out_hash: [u8; 32] = Keccak256::digest(out_ciphertext).into();
402    let mut h = Keccak256::new();
403    h.update(b"SpendAuth.action.v1");
404    h.update(chain_id_be32);
405    h.update(contract_addr_20);
406    h.update(nf_old_be32);
407    h.update(cmx_be32);
408    h.update(epk_be32);
409    h.update(&enc_hash);
410    h.update(&out_hash);
411    h.finalize().into()
412}
413
414// ── finalizeWithdraw(bytes32,uint256,bytes32) — legacy relayer path ──────────
415
416/// Arguments for `finalizeWithdraw(bytes32,uint256,bytes32)`.
417///
418/// Legacy federation-trusted path — kept for backward-compat.
419/// New deployments should prefer `unshield()`.
420#[derive(Debug, Clone)]
421pub struct FinalizeWithdrawCalldataArgs {
422    pub nf: [u8; 32],
423    pub amount_sats: u64,
424    pub recipient_meta: [u8; 32],
425}
426
427/// First 4 bytes of `keccak256("finalizeWithdraw(bytes32,uint256,bytes32)")`.
428pub fn finalize_withdraw_function_selector() -> [u8; 4] {
429    Keccak256::digest(b"finalizeWithdraw(bytes32,uint256,bytes32)")[..4]
430        .try_into()
431        .expect("selector is 4 bytes")
432}
433
434/// ABI-encode `finalizeWithdraw` calldata (selector + body).
435pub fn encode_finalize_withdraw_calldata(args: &FinalizeWithdrawCalldataArgs) -> Vec<u8> {
436    let tokens = vec![
437        Token::FixedBytes(args.nf.to_vec()),
438        Token::Uint(args.amount_sats.into()),
439        Token::FixedBytes(args.recipient_meta.to_vec()),
440    ];
441    let body = encode(&tokens);
442    let mut out = Vec::with_capacity(4 + body.len());
443    out.extend_from_slice(&finalize_withdraw_function_selector());
444    out.extend_from_slice(&body);
445    out
446}
447
448// ── PrivacyERC.shield() calldata ──────────────────────────────────────────────
449//
450// Signature:
451//   shield(
452//     (bytes32,bytes,bytes,bytes32,bytes32,bytes32,bytes,uint256[8],uint256[3])[] actions,
453//     uint256 amount,
454//     address owner,
455//     uint256 deadline,
456//     uint8   v,
457//     bytes32 r,
458//     bytes32 s,
459//     uint256[3] bindingSig
460//   )
461//
462// For native ETH pools (isNativeEth=true) the permit params (owner/deadline/v/r/s)
463// are ignored by the contract; pass zeroes.
464
465/// Arguments for `PrivacyERC.shield()`.
466#[derive(Debug, Clone)]
467pub struct ErcShieldCalldataArgs {
468    pub actions: Vec<BundleActionArgs>,
469    /// Token amount in the token's smallest unit (wei / 6-decimal USDC unit / …).
470    pub amount: u128,
471    /// EIP-2612 permit: token owner address (20 bytes, right-padded to 32).
472    pub owner: [u8; 20],
473    /// EIP-2612 permit: expiry unix timestamp.
474    pub deadline: u64,
475    /// EIP-2612 permit: signature v (1 byte).
476    pub permit_v: u8,
477    /// EIP-2612 permit: signature r (32 bytes).
478    pub permit_r: [u8; 32],
479    /// EIP-2612 permit: signature s (32 bytes).
480    pub permit_s: [u8; 32],
481    /// Baby JubJub Schnorr binding signature [Rx, Ry, s].
482    pub binding_sig: [[u8; 32]; 3],
483}
484
485/// First 4 bytes of the `PrivacyERC.shield()` function selector.
486pub fn erc_shield_function_selector() -> [u8; 4] {
487    Keccak256::digest(
488        b"shield((bytes32,bytes,bytes,bytes32,bytes32,bytes32,bytes,uint256[8],uint256[3])[],uint256,address,uint256,uint8,bytes32,bytes32,uint256[3])",
489    )[..4]
490    .try_into()
491    .expect("selector is 4 bytes")
492}
493
494/// ABI-encode `PrivacyERC.shield()` calldata (selector + body).
495pub fn encode_erc_shield_calldata(args: &ErcShieldCalldataArgs) -> Result<Vec<u8>, EthEncodeError> {
496    let actions_token = Token::Array(
497        args.actions
498            .iter()
499            .map(|a| {
500                if a.enc_ciphertext.len() != 580 {
501                    return Err(EthEncodeError::BadEncLen(a.enc_ciphertext.len()));
502                }
503                let pub_fields_token = Token::FixedArray(
504                    a.pub_fields
505                        .iter()
506                        .map(|x| Token::Uint(Uint::from_big_endian(x)))
507                        .collect(),
508                );
509                let spend_auth_token = Token::FixedArray(
510                    a.spend_auth_sig
511                        .iter()
512                        .map(|x| Token::Uint(Uint::from_big_endian(x)))
513                        .collect(),
514                );
515                Ok(Token::Tuple(vec![
516                    Token::FixedBytes(a.cmx.to_vec()),
517                    Token::Bytes(a.enc_ciphertext.clone()),
518                    Token::Bytes(a.out_ciphertext.clone()),
519                    Token::FixedBytes(a.epk.to_vec()),
520                    Token::FixedBytes(a.nf_old.to_vec()),
521                    Token::FixedBytes(a.anchor.to_vec()),
522                    Token::Bytes(a.proof.clone()),
523                    pub_fields_token,
524                    spend_auth_token,
525                ]))
526            })
527            .collect::<Result<Vec<_>, _>>()?,
528    );
529
530    // owner address as ABI address token
531    let owner_addr = ethabi::Address::from(args.owner);
532
533    let binding_sig_token = Token::FixedArray(
534        args.binding_sig
535            .iter()
536            .map(|x| Token::Uint(Uint::from_big_endian(x)))
537            .collect(),
538    );
539
540    let tokens = vec![
541        actions_token,
542        Token::Uint(args.amount.into()),
543        Token::Address(owner_addr),
544        Token::Uint(args.deadline.into()),
545        Token::Uint(args.permit_v.into()),
546        Token::FixedBytes(args.permit_r.to_vec()),
547        Token::FixedBytes(args.permit_s.to_vec()),
548        binding_sig_token,
549    ];
550
551    let body = encode(&tokens);
552    let mut out = Vec::with_capacity(4 + body.len());
553    out.extend_from_slice(&erc_shield_function_selector());
554    out.extend_from_slice(&body);
555    Ok(out)
556}
557
558// ── PrivacyERC unshield helpers ───────────────────────────────────────────────
559
560/// Encode an EVM address (20 bytes) into the 32-byte `recipientMeta` field used
561/// by `PrivacyERC._onAssetRelease()`.  The address occupies the low 20 bytes
562/// (right-aligned), matching `address(uint160(uint256(recipientMeta)))`.
563pub fn evm_address_to_recipient_meta(addr: &[u8; 20]) -> [u8; 32] {
564    let mut meta = [0u8; 32];
565    meta[12..].copy_from_slice(addr);
566    meta
567}
568
569/// Parse a 0x-prefixed EVM address hex string into 20 bytes.
570pub fn parse_evm_address_hex(s: &str) -> Result<[u8; 20], BindingSighashError> {
571    let clean = s.strip_prefix("0x").unwrap_or(s);
572    if clean.len() != 40 {
573        return Err(BindingSighashError::Hex(format!("expected 40 hex chars, got {}", clean.len())));
574    }
575    let bytes = hex::decode(clean).map_err(|e| BindingSighashError::Hex(e.to_string()))?;
576    Ok(bytes.try_into().expect("40 hex chars = 20 bytes"))
577}
578
579// ── Tests ─────────────────────────────────────────────────────────────────────
580
581#[cfg(test)]
582mod tests {
583    use super::*;
584
585    #[test]
586    fn unshield_value_balance_roundtrip() {
587        // Unshield: positive satoshis, bit255 = 0.
588        let amount: u64 = 100_000;
589        let vb = bundle_value_balance_be(amount, false);
590        let mut expected = [0u8; 32];
591        expected[24..32].copy_from_slice(&amount.to_be_bytes());
592        assert_eq!(vb, expected);
593    }
594
595    #[test]
596    fn finalize_withdraw_calldata_prefixes_selector() {
597        let cd = encode_finalize_withdraw_calldata(&FinalizeWithdrawCalldataArgs {
598            nf: [7u8; 32],
599            amount_sats: 123_456,
600            recipient_meta: [8u8; 32],
601        });
602        assert!(cd.len() > 4);
603        assert_eq!(&cd[..4], &finalize_withdraw_function_selector());
604    }
605
606    /// Decode the actual failing calldata and inspect the BundleAction fields.
607    #[test]
608    fn decode_failing_calldata_fields() {
609        // First 10468 bytes of the failing calldata (skip selector)
610        let raw_hex = "00000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000005dc00000000000000000000000000000000000000000000000000000000000005dc00000000000000000000000000000000000000000000000000000000000000001c2fbf7c1d370880bd19943877bf41259025e4e877c56fbf26c4576b0e809b001d9022ffbe1768250cedb08257b1776b2f248a0854cd1a7245c42ae2c63ca5650446c7c76fa1736f7e8c47994073011a534af17c591b3d0b51e25b0c5cf57b5b000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000202dab531982047374d79feb10ee3389c3aae694c9d9a76696f1d034c245ac8263000000000000000000000000000000000000000000000000000000000000022000000000000000000000000000000000000000000000000000000000000004a03955242e589537ceca6b866b35cc16bad5605602c8a2bee463bb72612b6dbd9004cc117c66893f069f160294b22653b890e04abe242379d2dd98956778386fd400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000052000000000000000000000000000000000000000000000000000000000000000003de55e0d54c5787b64a73d7879b67b9386e7262c20b6963c32d543462bdbc0e16858a895cb5ac289f5af0f0ad2918d6167a63370ed12dd7cd2210e53b6daee004cc117c66893f069f160294b22653b890e04abe242379d2dd98956778386fd417bc7b3fea209a7f90bb47b68453956704bcc38c3d32b1e1ef3c1909ec559f871f4fa732aa884e7020dccf264cf39bfade91074f00ec7b684f170b18531c4d4b2dab531982047374d79feb10ee3389c3aae694c9d9a76696f1d034c245ac82630ba6a64dab4e9fa2f31c9f5a35f3a93c323b160c175398b260a271a094c0fa7713460b57b63c8619062eff726135cabd3a4faf6c56e51afbf173a885d698f5ec026015d5bd814267fb2c44bc2a2336dfa946866960315f49761800cae310ed2c0000000000000000000000000000000000000000000000000000000000000244";
611
612        let body = hex::decode(raw_hex).expect("valid hex");
613
614        let read_u256_at = |offset: usize| -> u128 {
615            let word = &body[offset..offset+32];
616            u128::from_be_bytes(word[16..32].try_into().unwrap())
617        };
618        let read_bytes32_at = |offset: usize| -> String {
619            hex::encode(&body[offset..offset+32])
620        };
621
622        // Top-level layout
623        println!("=== Top-level ===");
624        println!("W0 offset_to_actions: {}", read_u256_at(0));
625        println!("W1 valueBalance:       {}", read_u256_at(32));
626        println!("W2 amount:             {}", read_u256_at(64));
627        println!("W3 recipientMeta:      {}", read_bytes32_at(96));
628        println!("W4 bindingSig[0]:      {}", read_bytes32_at(128));
629        println!("W5 bindingSig[1]:      {}", read_bytes32_at(160));
630        println!("W6 bindingSig[2]:      {}", read_bytes32_at(192));
631
632        // actions[] starts at offset 224 (= 7*32)
633        println!("\n=== actions[] at body offset 224 ===");
634        println!("W7 length:             {}", read_u256_at(224));
635        println!("W8 elem0 offset:       {}", read_u256_at(256));
636
637        // struct elem 0 at body offset 288 (= 9*32)
638        let s = 288_usize;
639        println!("\n=== BundleAction[0] struct at body offset {} ===", s);
640        println!("  cmx:           {}", read_bytes32_at(s));
641        let enc_off = read_u256_at(s + 32)  as usize;
642        let out_off = read_u256_at(s + 64)  as usize;
643        let proof_off= read_u256_at(s + 192) as usize;  // slot 6 (192 = 6*32)
644        println!("  enc_offset:    {} (0x{:x})", enc_off, enc_off);
645        println!("  out_offset:    {} (0x{:x})", out_off, out_off);
646        println!("  epk:           {}", read_bytes32_at(s + 96));
647        println!("  nfOld:         {}", read_bytes32_at(s + 128));
648        println!("  anchor:        {}", read_bytes32_at(s + 160));
649        println!("  proof_offset:  {} (0x{:x}) [should be 1312 = 0x520]", proof_off, proof_off);
650        println!("  pubInputs[0]:  {}", read_bytes32_at(s + 224));
651        println!("  pubInputs[1]:  {}", read_bytes32_at(s + 256));
652        println!("  pubInputs[6]:  {}", read_bytes32_at(s + 224 + 6*32));
653
654        // Check what's at the proof data location
655        let proof_data_body_off = s + proof_off;
656        println!("\n=== Proof data at body offset {} (struct + {}) ===", proof_data_body_off, proof_off);
657        if proof_data_body_off + 32 <= body.len() {
658            let claimed_len = read_u256_at(proof_data_body_off);
659            println!("  claimed length: {} (0x{:x})", claimed_len, claimed_len);
660        } else {
661            println!("  OUT OF BOUNDS (body len = {})", body.len());
662        }
663
664        // Check enc data
665        let enc_abs = s + enc_off;
666        if enc_abs + 32 <= body.len() {
667            let enc_len = read_u256_at(enc_abs);
668            println!("\nenc data at body offset {}: length = {} (expected 580)", enc_abs, enc_len);
669        }
670
671        // Confirm proof_offset is wrong
672        assert_eq!(proof_off, 82, "confirmed proof_offset = 82 = 0x52 (BUG)");
673        assert_ne!(proof_off, 1312, "proof_offset should be 1312 but it is 82");
674    }
675    ///
676    /// BundleAction static header = 9 fields, all 32 bytes each in head:
677    ///   cmx(32) + enc_off(32) + out_off(32) + epk(32) + nfOld(32)
678    ///   + anchor(32) + proof_off(32) + pubFields[8](256) + spendAuth[3](96)
679    ///   = 576 bytes total head
680    ///
681    /// Dynamic data layout:
682    ///   enc  at offset 576        (32 len + 580 data padded to 608)  → end 576+640=1216
683    ///   out  at offset 1216       (32 len +  80 data padded to  96)  → end 1216+128=1344
684    ///   proof at offset 1344 = 0x540
685    #[test]
686    fn bundle_calldata_proof_offset_is_correct() {
687        let proof_bytes = vec![0xabu8; 256]; // Groth16 abi.encode(pA,pB,pC) size
688        let enc = vec![0u8; 580];
689        let out = vec![0u8; 80];
690
691        let cd = encode_bundle_calldata(&BundleCalldataArgs {
692            actions: vec![BundleActionArgs {
693                cmx:            [1u8; 32],
694                enc_ciphertext: enc,
695                out_ciphertext: out,
696                epk:            [2u8; 32],
697                nf_old:         [3u8; 32],
698                anchor:         [4u8; 32],
699                proof:          proof_bytes,
700                pub_fields:     [[5u8; 32]; 8],
701                spend_auth_sig: [[6u8; 32]; 3],
702            }],
703            value_balance:  [0u8; 32],
704            amount:         0,
705            recipient_meta: [0u8; 32],
706            binding_sig:    [[7u8; 32]; 3],
707        })
708        .expect("encode_bundle_calldata failed");
709
710        // Skip selector (4 bytes).  Body layout:
711        //   W0  = offset_to_actions (= 0xe0 = 224)
712        //   W1  = valueBalance
713        //   W2  = amount
714        //   W3  = recipientMeta
715        //   W4-W6 = bindingSig[3]
716        //   W7  = actions.length (1)
717        //   W8  = offset_to_elem0 (32)
718        //   W9  = struct elem0 starts here
719        //
720        // Within struct elem0 (offsets relative to W9):
721        //   +0   = cmx
722        //   +32  = enc_offset    ← should be 576 = 0x240
723        //   +64  = out_offset    ← should be 1216 = 0x4c0
724        //   +96  = epk
725        //   +128 = nfOld
726        //   +160 = anchor
727        //   +192 = proof_offset  ← should be 1344 = 0x540
728        //   +224..+479 = pubFields[8]
729        //   +480..+575 = spendAuthSig[3]
730
731        let body = &cd[4..]; // skip selector
732
733        // struct elem0 starts at word 9 (= byte 288)
734        let struct_start = 9 * 32_usize;
735
736        let read_u256 = |offset: usize| -> u128 {
737            let word = &body[offset..offset + 32];
738            u128::from_be_bytes(word[16..32].try_into().unwrap())
739        };
740
741        let enc_offset   = read_u256(struct_start + 32);   // +32
742        let out_offset   = read_u256(struct_start + 64);   // +64
743        let proof_offset = read_u256(struct_start + 192);  // +192
744
745        assert_eq!(enc_offset,   576,  "enc_offset should be 0x240 = 576");
746        assert_eq!(out_offset,   1216, "out_offset should be 0x4c0 = 1216");
747        assert_eq!(proof_offset, 1344, "proof_offset should be 0x540 = 1344, got {proof_offset:#x}");
748    }
749}