privacy-core 0.1.4

Shared, halo2-free foundation for privacy-indexer and privacy-relayer (event decode, calldata encode, plain data types, shield intents).
Documentation
//! Calldata encoders for the pERC20 standard family — `PERC20` (issuer-minted) and
//! `WrappedPERC20` (backed shield/unshield) — plus the application-layer `SwapCoordinator`.
//!
//! These mirror the on-chain ABI exactly (see the PERC20 repo's `privacybtc-ethereum`):
//! every entrypoint takes a `PrivacyCall` tuple
//!   `(bytes actions, uint256[3] bindingSig)`
//! where `actions == abi.encode(IEndpointCore.BundleAction[])`. The relayer forwards the
//! already-signed bundle (v2 sighash, incl. `executor`) verbatim; it never re-signs.

use super::{BundleActionArgs, EthEncodeError};
use ethabi::{encode, Token, Uint};
use sha3::{Digest, Keccak256};

/// A `PrivacyCall` — an already-proved, already-signed bundle ready for submission.
#[derive(Debug, Clone)]
pub struct PrivacyCallArgs {
    pub actions: Vec<BundleActionArgs>,
    /// Baby JubJub Schnorr binding signature `[Rx, Ry, s]` over the v2 bundle sighash.
    pub binding_sig: [[u8; 32]; 3],
}

fn selector(signature: &[u8]) -> [u8; 4] {
    Keccak256::digest(signature)[..4]
        .try_into()
        .expect("selector is 4 bytes")
}

/// `IEndpointCore.BundleAction[]` as an ethabi token (shared with `bundle()` layout).
fn bundle_actions_token(actions: &[BundleActionArgs]) -> Token {
    Token::Array(
        actions
            .iter()
            .map(|a| {
                let pub_fields_token = Token::FixedArray(
                    a.pub_fields
                        .iter()
                        .map(|b| Token::Uint(Uint::from_big_endian(b)))
                        .collect(),
                );
                let spend_auth_sig_token = Token::FixedArray(
                    a.spend_auth_sig
                        .iter()
                        .map(|b| Token::Uint(Uint::from_big_endian(b)))
                        .collect(),
                );
                Token::Tuple(vec![
                    Token::FixedBytes(a.cmx.to_vec()),
                    Token::Bytes(a.enc_ciphertext.clone()),
                    Token::Bytes(a.out_ciphertext.clone()),
                    Token::FixedBytes(a.epk.to_vec()),
                    Token::FixedBytes(a.nf_old.to_vec()),
                    Token::FixedBytes(a.anchor.to_vec()),
                    Token::Bytes(a.proof.clone()),
                    pub_fields_token,
                    spend_auth_sig_token,
                ])
            })
            .collect(),
    )
}

/// The `PrivacyCall` tuple token: `(bytes abi.encode(BundleAction[]), uint256[3] bindingSig)`.
fn privacy_call_token(call: &PrivacyCallArgs) -> Token {
    let actions_bytes = encode(&[bundle_actions_token(&call.actions)]);
    let binding_sig_token = Token::FixedArray(
        call.binding_sig
            .iter()
            .map(|b| Token::Uint(Uint::from_big_endian(b)))
            .collect(),
    );
    Token::Tuple(vec![Token::Bytes(actions_bytes), binding_sig_token])
}

/// `keccak256(abi.encode(PrivacyCall))` — the commitment the `SwapCoordinator` stores for
/// each leg (`commitA`/`commitB`). Must match `keccak256(abi.encode(call))` on-chain.
pub fn privacy_call_commit(call: &PrivacyCallArgs) -> [u8; 32] {
    let encoded = encode(&[privacy_call_token(call)]);
    Keccak256::digest(&encoded).into()
}

fn with_selector(sel: [u8; 4], body: Vec<u8>) -> Vec<u8> {
    let mut out = Vec::with_capacity(4 + body.len());
    out.extend_from_slice(&sel);
    out.extend_from_slice(&body);
    out
}

// ── PERC20 transfer (permissionless + executor-gated) ────────────────────────

/// `transfer((bytes,uint256[3]))` — permissionless value-neutral transfer. Selector `0xeda1a0ac`.
pub fn encode_perc20_transfer_calldata(call: &PrivacyCallArgs) -> Vec<u8> {
    let body = encode(&[privacy_call_token(call)]);
    with_selector(selector(b"transfer((bytes,uint256[3]))"), body)
}

/// `transfer(address,(bytes,uint256[3]))` — executor-gated transfer (atomic-swap leg).
/// Selector `0xc7b921d3`. `executor` MUST equal the bound `executor` in the v2 sighash
/// (typically the `SwapCoordinator`).
pub fn encode_perc20_transfer_executor_calldata(
    executor: &[u8; 20],
    call: &PrivacyCallArgs,
) -> Vec<u8> {
    let tokens = vec![
        Token::Address(ethabi::Address::from(*executor)),
        privacy_call_token(call),
    ];
    let body = encode(&tokens);
    with_selector(selector(b"transfer(address,(bytes,uint256[3]))"), body)
}

// ── WrappedPERC20 shield / unshield ──────────────────────────────────────────

/// `shield(uint256,(bytes,uint256[3]))` — deposit underlying → mint shielded note.
/// Selector `0x0411cbab`. `amount_units` is in NOTE UNITS (the contract pulls
/// `amount_units * scale` of the underlying from `msg.sender`).
pub fn encode_wrapped_shield_calldata(amount_units: u64, call: &PrivacyCallArgs) -> Vec<u8> {
    let tokens = vec![Token::Uint(Uint::from(amount_units)), privacy_call_token(call)];
    let body = encode(&tokens);
    with_selector(selector(b"shield(uint256,(bytes,uint256[3]))"), body)
}

/// `unshield(uint256,address,(bytes,uint256[3]))` — spend note → release underlying to
/// `recipient`. Selector `0x53644c61`. The recipient is bound into the binding sighash
/// on-chain (`recipientMeta = uint160(recipient)`), so it must match the proved bundle.
pub fn encode_wrapped_unshield_calldata(
    amount_units: u64,
    recipient: &[u8; 20],
    call: &PrivacyCallArgs,
) -> Vec<u8> {
    let tokens = vec![
        Token::Uint(Uint::from(amount_units)),
        Token::Address(ethabi::Address::from(*recipient)),
        privacy_call_token(call),
    ];
    let body = encode(&tokens);
    with_selector(selector(b"unshield(uint256,address,(bytes,uint256[3]))"), body)
}

// ── SwapCoordinator (3-tx atomic swap) ───────────────────────────────────────

/// `keccak256(abi.encode(initiator, poolA, poolB, htlcHash, commitA, rkBx, rkBy, salt))` — the
/// swap id the `SwapCoordinator` derives in `initiateSwap`. The relayer recomputes it locally so
/// it can issue `joinSwap`/`settle` without waiting to parse the receipt.
///
/// `rk_bx`/`rk_by` are the joiner's randomised spend-auth key coords (BE), pre-committed by the
/// initiator at `initiateSwap` (audit A-1): they are part of the swap id and the join challenge.
pub fn compute_swap_id(
    initiator: &[u8; 20],
    pool_a: &[u8; 20],
    pool_b: &[u8; 20],
    htlc_hash: &[u8; 32],
    commit_a: &[u8; 32],
    rk_bx: &[u8; 32],
    rk_by: &[u8; 32],
    salt: &[u8; 32],
) -> [u8; 32] {
    let encoded = encode(&[
        Token::Address(ethabi::Address::from(*initiator)),
        Token::Address(ethabi::Address::from(*pool_a)),
        Token::Address(ethabi::Address::from(*pool_b)),
        Token::FixedBytes(htlc_hash.to_vec()),
        Token::FixedBytes(commit_a.to_vec()),
        Token::Uint(Uint::from_big_endian(rk_bx)),
        Token::Uint(Uint::from_big_endian(rk_by)),
        Token::FixedBytes(salt.to_vec()),
    ]);
    Keccak256::digest(&encoded).into()
}

/// `initiateSwap(address,address,bytes32,bytes32,uint256,uint256,uint64,bytes32)` — selector
/// `0x6db7974d`. `rk_bx`/`rk_by` are the joiner's randomised spend-auth key coords (BE),
/// pre-committed by the initiator (audit A-1) so only the real counterparty can `joinSwap`.
pub fn encode_swap_initiate_calldata(
    pool_a: &[u8; 20],
    pool_b: &[u8; 20],
    htlc_hash: &[u8; 32],
    commit_a: &[u8; 32],
    rk_bx: &[u8; 32],
    rk_by: &[u8; 32],
    deadline: u64,
    salt: &[u8; 32],
) -> Vec<u8> {
    let tokens = vec![
        Token::Address(ethabi::Address::from(*pool_a)),
        Token::Address(ethabi::Address::from(*pool_b)),
        Token::FixedBytes(htlc_hash.to_vec()),
        Token::FixedBytes(commit_a.to_vec()),
        Token::Uint(Uint::from_big_endian(rk_bx)),
        Token::Uint(Uint::from_big_endian(rk_by)),
        Token::Uint(Uint::from(deadline)),
        Token::FixedBytes(salt.to_vec()),
    ];
    let body = encode(&tokens);
    with_selector(
        selector(b"initiateSwap(address,address,bytes32,bytes32,uint256,uint256,uint64,bytes32)"),
        body,
    )
}

/// `joinSwap(bytes32,bytes32,uint256[3])` — selector `0x8bbe821a`.
/// `rkB` is NOT supplied here — it was committed by the initiator at `initiateSwap` and is read
/// from storage. `joiner_sig` is the Baby JubJub Schnorr signature under `rkB` over the join
/// challenge, proving control of the pre-committed key.
pub fn encode_swap_join_calldata(
    swap_id: &[u8; 32],
    commit_b: &[u8; 32],
    joiner_sig: &[[u8; 32]; 3],
) -> Vec<u8> {
    let joiner_sig_token = Token::FixedArray(
        joiner_sig
            .iter()
            .map(|b| Token::Uint(Uint::from_big_endian(b)))
            .collect(),
    );
    let tokens = vec![
        Token::FixedBytes(swap_id.to_vec()),
        Token::FixedBytes(commit_b.to_vec()),
        joiner_sig_token,
    ];
    let body = encode(&tokens);
    with_selector(
        selector(b"joinSwap(bytes32,bytes32,uint256[3])"),
        body,
    )
}

/// `settle(bytes32,bytes32,(bytes,uint256[3]),(bytes,uint256[3]))` — selector `0xc7ece15f`.
/// Reveals the HTLC preimage and submits both executor-gated legs atomically.
pub fn encode_swap_settle_calldata(
    swap_id: &[u8; 32],
    secret: &[u8; 32],
    call_a: &PrivacyCallArgs,
    call_b: &PrivacyCallArgs,
) -> Vec<u8> {
    let tokens = vec![
        Token::FixedBytes(swap_id.to_vec()),
        Token::FixedBytes(secret.to_vec()),
        privacy_call_token(call_a),
        privacy_call_token(call_b),
    ];
    let body = encode(&tokens);
    with_selector(
        selector(b"settle(bytes32,bytes32,(bytes,uint256[3]),(bytes,uint256[3]))"),
        body,
    )
}

// ── selectors (handy for tests / dispatch) ───────────────────────────────────

pub fn perc20_transfer_selector() -> [u8; 4] { selector(b"transfer((bytes,uint256[3]))") }
pub fn perc20_transfer_executor_selector() -> [u8; 4] { selector(b"transfer(address,(bytes,uint256[3]))") }
pub fn wrapped_shield_selector() -> [u8; 4] { selector(b"shield(uint256,(bytes,uint256[3]))") }
pub fn wrapped_unshield_selector() -> [u8; 4] { selector(b"unshield(uint256,address,(bytes,uint256[3]))") }
pub fn swap_initiate_selector() -> [u8; 4] { selector(b"initiateSwap(address,address,bytes32,bytes32,uint256,uint256,uint64,bytes32)") }
pub fn swap_join_selector() -> [u8; 4] { selector(b"joinSwap(bytes32,bytes32,uint256[3])") }
pub fn swap_settle_selector() -> [u8; 4] { selector(b"settle(bytes32,bytes32,(bytes,uint256[3]),(bytes,uint256[3]))") }

// Keep `EthEncodeError` reachable for symmetry with the other encoders (none of these
// fixed-shape encoders can fail today, but callers may want a uniform error type).
#[allow(dead_code)]
fn _assert_error_in_scope(_e: EthEncodeError) {}

#[cfg(test)]
mod tests {
    use super::*;

    fn dummy_action() -> BundleActionArgs {
        BundleActionArgs {
            cmx: [1u8; 32],
            enc_ciphertext: vec![0u8; 580],
            out_ciphertext: vec![0u8; 80],
            epk: [2u8; 32],
            nf_old: [3u8; 32],
            anchor: [4u8; 32],
            proof: vec![0xabu8; 256],
            pub_fields: [[5u8; 32]; 8],
            spend_auth_sig: [[6u8; 32]; 3],
        }
    }

    fn dummy_call() -> PrivacyCallArgs {
        PrivacyCallArgs { actions: vec![dummy_action()], binding_sig: [[7u8; 32]; 3] }
    }

    #[test]
    fn selectors_match_onchain() {
        assert_eq!(perc20_transfer_selector(), [0xed, 0xa1, 0xa0, 0xac]);
        assert_eq!(perc20_transfer_executor_selector(), [0xc7, 0xb9, 0x21, 0xd3]);
        assert_eq!(wrapped_shield_selector(), [0x04, 0x11, 0xcb, 0xab]);
        assert_eq!(wrapped_unshield_selector(), [0x53, 0x64, 0x4c, 0x61]);
        assert_eq!(swap_initiate_selector(), [0x6d, 0xb7, 0x97, 0x4d]);
        assert_eq!(swap_join_selector(), [0x8b, 0xbe, 0x82, 0x1a]);
        assert_eq!(swap_settle_selector(), [0xc7, 0xec, 0xe1, 0x5f]);
    }

    #[test]
    fn calldata_prefixes_correct_selector() {
        let call = dummy_call();
        assert_eq!(&encode_perc20_transfer_calldata(&call)[..4], &perc20_transfer_selector());
        assert_eq!(
            &encode_perc20_transfer_executor_calldata(&[0xEFu8; 20], &call)[..4],
            &perc20_transfer_executor_selector()
        );
        assert_eq!(&encode_wrapped_shield_calldata(1000, &call)[..4], &wrapped_shield_selector());
        assert_eq!(
            &encode_wrapped_unshield_calldata(1000, &[0xDEu8; 20], &call)[..4],
            &wrapped_unshield_selector()
        );
    }

    #[test]
    fn shield_and_transfer_share_privacy_call_tail() {
        // shield(amount, call) body = uint256 amount ‖ <same PrivacyCall tail as transfer>.
        let call = dummy_call();
        let transfer = encode_perc20_transfer_calldata(&call);
        let shield = encode_wrapped_shield_calldata(1000, &call);
        // transfer body (after selector) is the offset-encoded PrivacyCall starting at word 0;
        // shield body has the amount in word 0 then the PrivacyCall offset at word 1. The
        // encoded PrivacyCall dynamic tail (actions bytes + bindingSig) must be byte-identical.
        let t_tail = &transfer[4 + 32..]; // skip selector + head offset word
        let s_tail = &shield[4 + 64..]; // skip selector + amount + offset word
        assert_eq!(t_tail, s_tail, "PrivacyCall encoding must be reused verbatim");
    }

    #[test]
    fn commit_is_deterministic_keccak() {
        let call = dummy_call();
        let c1 = privacy_call_commit(&call);
        let c2 = privacy_call_commit(&call);
        assert_eq!(c1, c2);
        // A different binding sig changes the commitment.
        let mut other = call.clone();
        other.binding_sig[2] = [0x9u8; 32];
        assert_ne!(privacy_call_commit(&other), c1);
    }

    #[test]
    fn swap_id_matches_abi_encode_layout() {
        // Deterministic + sensitive to each field (incl. the pre-committed joiner key rkB).
        let base = compute_swap_id(&[1u8; 20], &[2u8; 20], &[3u8; 20], &[4u8; 32], &[5u8; 32], &[8u8; 32], &[9u8; 32], &[6u8; 32]);
        assert_eq!(
            base,
            compute_swap_id(&[1u8; 20], &[2u8; 20], &[3u8; 20], &[4u8; 32], &[5u8; 32], &[8u8; 32], &[9u8; 32], &[6u8; 32])
        );
        assert_ne!(
            base,
            compute_swap_id(&[1u8; 20], &[2u8; 20], &[3u8; 20], &[4u8; 32], &[5u8; 32], &[8u8; 32], &[9u8; 32], &[7u8; 32])
        );
        // Changing rkB alone must change the id.
        assert_ne!(
            base,
            compute_swap_id(&[1u8; 20], &[2u8; 20], &[3u8; 20], &[4u8; 32], &[5u8; 32], &[0xAu8; 32], &[9u8; 32], &[6u8; 32])
        );
    }
}