Skip to main content

privacy_core/ethereum/
perc20.rs

1//! Calldata encoders for the pERC20 standard family — `PERC20` (issuer-minted) and
2//! `WrappedPERC20` (backed shield/unshield) — plus the application-layer `SwapCoordinator`.
3//!
4//! These mirror the on-chain ABI exactly (see the PERC20 repo's `privacybtc-ethereum`):
5//! every entrypoint takes a `PrivacyCall` tuple
6//!   `(bytes actions, uint256[3] bindingSig)`
7//! where `actions == abi.encode(IEndpointCore.BundleAction[])`. The relayer forwards the
8//! already-signed bundle (v2 sighash, incl. `executor`) verbatim; it never re-signs.
9
10use super::{BundleActionArgs, EthEncodeError};
11use ethabi::{encode, Token, Uint};
12use sha3::{Digest, Keccak256};
13
14/// A `PrivacyCall` — an already-proved, already-signed bundle ready for submission.
15#[derive(Debug, Clone)]
16pub struct PrivacyCallArgs {
17    pub actions: Vec<BundleActionArgs>,
18    /// Baby JubJub Schnorr binding signature `[Rx, Ry, s]` over the v2 bundle sighash.
19    pub binding_sig: [[u8; 32]; 3],
20}
21
22fn selector(signature: &[u8]) -> [u8; 4] {
23    Keccak256::digest(signature)[..4]
24        .try_into()
25        .expect("selector is 4 bytes")
26}
27
28/// `IEndpointCore.BundleAction[]` as an ethabi token (shared with `bundle()` layout).
29fn bundle_actions_token(actions: &[BundleActionArgs]) -> Token {
30    Token::Array(
31        actions
32            .iter()
33            .map(|a| {
34                let pub_fields_token = Token::FixedArray(
35                    a.pub_fields
36                        .iter()
37                        .map(|b| Token::Uint(Uint::from_big_endian(b)))
38                        .collect(),
39                );
40                let spend_auth_sig_token = Token::FixedArray(
41                    a.spend_auth_sig
42                        .iter()
43                        .map(|b| Token::Uint(Uint::from_big_endian(b)))
44                        .collect(),
45                );
46                Token::Tuple(vec![
47                    Token::FixedBytes(a.cmx.to_vec()),
48                    Token::Bytes(a.enc_ciphertext.clone()),
49                    Token::Bytes(a.out_ciphertext.clone()),
50                    Token::FixedBytes(a.epk.to_vec()),
51                    Token::FixedBytes(a.nf_old.to_vec()),
52                    Token::FixedBytes(a.anchor.to_vec()),
53                    Token::Bytes(a.proof.clone()),
54                    pub_fields_token,
55                    spend_auth_sig_token,
56                ])
57            })
58            .collect(),
59    )
60}
61
62/// The `PrivacyCall` tuple token: `(bytes abi.encode(BundleAction[]), uint256[3] bindingSig)`.
63fn privacy_call_token(call: &PrivacyCallArgs) -> Token {
64    let actions_bytes = encode(&[bundle_actions_token(&call.actions)]);
65    let binding_sig_token = Token::FixedArray(
66        call.binding_sig
67            .iter()
68            .map(|b| Token::Uint(Uint::from_big_endian(b)))
69            .collect(),
70    );
71    Token::Tuple(vec![Token::Bytes(actions_bytes), binding_sig_token])
72}
73
74/// `keccak256(abi.encode(PrivacyCall))` — the commitment the `SwapCoordinator` stores for
75/// each leg (`commitA`/`commitB`). Must match `keccak256(abi.encode(call))` on-chain.
76pub fn privacy_call_commit(call: &PrivacyCallArgs) -> [u8; 32] {
77    let encoded = encode(&[privacy_call_token(call)]);
78    Keccak256::digest(&encoded).into()
79}
80
81fn with_selector(sel: [u8; 4], body: Vec<u8>) -> Vec<u8> {
82    let mut out = Vec::with_capacity(4 + body.len());
83    out.extend_from_slice(&sel);
84    out.extend_from_slice(&body);
85    out
86}
87
88// ── PERC20 transfer (permissionless + executor-gated) ────────────────────────
89
90/// `transfer((bytes,uint256[3]))` — permissionless value-neutral transfer. Selector `0xeda1a0ac`.
91pub fn encode_perc20_transfer_calldata(call: &PrivacyCallArgs) -> Vec<u8> {
92    let body = encode(&[privacy_call_token(call)]);
93    with_selector(selector(b"transfer((bytes,uint256[3]))"), body)
94}
95
96/// `transfer(address,(bytes,uint256[3]))` — executor-gated transfer (atomic-swap leg).
97/// Selector `0xc7b921d3`. `executor` MUST equal the bound `executor` in the v2 sighash
98/// (typically the `SwapCoordinator`).
99pub fn encode_perc20_transfer_executor_calldata(
100    executor: &[u8; 20],
101    call: &PrivacyCallArgs,
102) -> Vec<u8> {
103    let tokens = vec![
104        Token::Address(ethabi::Address::from(*executor)),
105        privacy_call_token(call),
106    ];
107    let body = encode(&tokens);
108    with_selector(selector(b"transfer(address,(bytes,uint256[3]))"), body)
109}
110
111// ── WrappedPERC20 shield / unshield ──────────────────────────────────────────
112
113/// `shield(uint256,(bytes,uint256[3]))` — deposit underlying → mint shielded note.
114/// Selector `0x0411cbab`. `amount_units` is in NOTE UNITS (the contract pulls
115/// `amount_units * scale` of the underlying from `msg.sender`).
116pub fn encode_wrapped_shield_calldata(amount_units: u64, call: &PrivacyCallArgs) -> Vec<u8> {
117    let tokens = vec![Token::Uint(Uint::from(amount_units)), privacy_call_token(call)];
118    let body = encode(&tokens);
119    with_selector(selector(b"shield(uint256,(bytes,uint256[3]))"), body)
120}
121
122/// `unshield(uint256,address,(bytes,uint256[3]))` — spend note → release underlying to
123/// `recipient`. Selector `0x53644c61`. The recipient is bound into the binding sighash
124/// on-chain (`recipientMeta = uint160(recipient)`), so it must match the proved bundle.
125pub fn encode_wrapped_unshield_calldata(
126    amount_units: u64,
127    recipient: &[u8; 20],
128    call: &PrivacyCallArgs,
129) -> Vec<u8> {
130    let tokens = vec![
131        Token::Uint(Uint::from(amount_units)),
132        Token::Address(ethabi::Address::from(*recipient)),
133        privacy_call_token(call),
134    ];
135    let body = encode(&tokens);
136    with_selector(selector(b"unshield(uint256,address,(bytes,uint256[3]))"), body)
137}
138
139// ── SwapCoordinator (3-tx atomic swap) ───────────────────────────────────────
140
141/// `keccak256(abi.encode(initiator, poolA, poolB, htlcHash, commitA, salt))` — the swap id
142/// the `SwapCoordinator` derives in `initiateSwap`. The relayer recomputes it locally so it
143/// can issue `joinSwap`/`settle` without waiting to parse the receipt.
144pub fn compute_swap_id(
145    initiator: &[u8; 20],
146    pool_a: &[u8; 20],
147    pool_b: &[u8; 20],
148    htlc_hash: &[u8; 32],
149    commit_a: &[u8; 32],
150    salt: &[u8; 32],
151) -> [u8; 32] {
152    let encoded = encode(&[
153        Token::Address(ethabi::Address::from(*initiator)),
154        Token::Address(ethabi::Address::from(*pool_a)),
155        Token::Address(ethabi::Address::from(*pool_b)),
156        Token::FixedBytes(htlc_hash.to_vec()),
157        Token::FixedBytes(commit_a.to_vec()),
158        Token::FixedBytes(salt.to_vec()),
159    ]);
160    Keccak256::digest(&encoded).into()
161}
162
163/// `initiateSwap(address,address,bytes32,bytes32,uint64,bytes32)` — selector `0x1e179f2a`.
164pub fn encode_swap_initiate_calldata(
165    pool_a: &[u8; 20],
166    pool_b: &[u8; 20],
167    htlc_hash: &[u8; 32],
168    commit_a: &[u8; 32],
169    deadline: u64,
170    salt: &[u8; 32],
171) -> Vec<u8> {
172    let tokens = vec![
173        Token::Address(ethabi::Address::from(*pool_a)),
174        Token::Address(ethabi::Address::from(*pool_b)),
175        Token::FixedBytes(htlc_hash.to_vec()),
176        Token::FixedBytes(commit_a.to_vec()),
177        Token::Uint(Uint::from(deadline)),
178        Token::FixedBytes(salt.to_vec()),
179    ];
180    let body = encode(&tokens);
181    with_selector(
182        selector(b"initiateSwap(address,address,bytes32,bytes32,uint64,bytes32)"),
183        body,
184    )
185}
186
187/// `joinSwap(bytes32,bytes32,uint256,uint256,uint256[3])` — selector `0x256e1950`.
188/// `rk_bx`/`rk_by` are the joiner's randomised spend-auth key coords (BE); `joiner_sig`
189/// is its Baby JubJub Schnorr signature over the join challenge.
190pub fn encode_swap_join_calldata(
191    swap_id: &[u8; 32],
192    commit_b: &[u8; 32],
193    rk_bx: &[u8; 32],
194    rk_by: &[u8; 32],
195    joiner_sig: &[[u8; 32]; 3],
196) -> Vec<u8> {
197    let joiner_sig_token = Token::FixedArray(
198        joiner_sig
199            .iter()
200            .map(|b| Token::Uint(Uint::from_big_endian(b)))
201            .collect(),
202    );
203    let tokens = vec![
204        Token::FixedBytes(swap_id.to_vec()),
205        Token::FixedBytes(commit_b.to_vec()),
206        Token::Uint(Uint::from_big_endian(rk_bx)),
207        Token::Uint(Uint::from_big_endian(rk_by)),
208        joiner_sig_token,
209    ];
210    let body = encode(&tokens);
211    with_selector(
212        selector(b"joinSwap(bytes32,bytes32,uint256,uint256,uint256[3])"),
213        body,
214    )
215}
216
217/// `settle(bytes32,bytes32,(bytes,uint256[3]),(bytes,uint256[3]))` — selector `0xc7ece15f`.
218/// Reveals the HTLC preimage and submits both executor-gated legs atomically.
219pub fn encode_swap_settle_calldata(
220    swap_id: &[u8; 32],
221    secret: &[u8; 32],
222    call_a: &PrivacyCallArgs,
223    call_b: &PrivacyCallArgs,
224) -> Vec<u8> {
225    let tokens = vec![
226        Token::FixedBytes(swap_id.to_vec()),
227        Token::FixedBytes(secret.to_vec()),
228        privacy_call_token(call_a),
229        privacy_call_token(call_b),
230    ];
231    let body = encode(&tokens);
232    with_selector(
233        selector(b"settle(bytes32,bytes32,(bytes,uint256[3]),(bytes,uint256[3]))"),
234        body,
235    )
236}
237
238// ── selectors (handy for tests / dispatch) ───────────────────────────────────
239
240pub fn perc20_transfer_selector() -> [u8; 4] { selector(b"transfer((bytes,uint256[3]))") }
241pub fn perc20_transfer_executor_selector() -> [u8; 4] { selector(b"transfer(address,(bytes,uint256[3]))") }
242pub fn wrapped_shield_selector() -> [u8; 4] { selector(b"shield(uint256,(bytes,uint256[3]))") }
243pub fn wrapped_unshield_selector() -> [u8; 4] { selector(b"unshield(uint256,address,(bytes,uint256[3]))") }
244pub fn swap_initiate_selector() -> [u8; 4] { selector(b"initiateSwap(address,address,bytes32,bytes32,uint64,bytes32)") }
245pub fn swap_join_selector() -> [u8; 4] { selector(b"joinSwap(bytes32,bytes32,uint256,uint256,uint256[3])") }
246pub fn swap_settle_selector() -> [u8; 4] { selector(b"settle(bytes32,bytes32,(bytes,uint256[3]),(bytes,uint256[3]))") }
247
248// Keep `EthEncodeError` reachable for symmetry with the other encoders (none of these
249// fixed-shape encoders can fail today, but callers may want a uniform error type).
250#[allow(dead_code)]
251fn _assert_error_in_scope(_e: EthEncodeError) {}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    fn dummy_action() -> BundleActionArgs {
258        BundleActionArgs {
259            cmx: [1u8; 32],
260            enc_ciphertext: vec![0u8; 580],
261            out_ciphertext: vec![0u8; 80],
262            epk: [2u8; 32],
263            nf_old: [3u8; 32],
264            anchor: [4u8; 32],
265            proof: vec![0xabu8; 256],
266            pub_fields: [[5u8; 32]; 8],
267            spend_auth_sig: [[6u8; 32]; 3],
268        }
269    }
270
271    fn dummy_call() -> PrivacyCallArgs {
272        PrivacyCallArgs { actions: vec![dummy_action()], binding_sig: [[7u8; 32]; 3] }
273    }
274
275    #[test]
276    fn selectors_match_onchain() {
277        assert_eq!(perc20_transfer_selector(), [0xed, 0xa1, 0xa0, 0xac]);
278        assert_eq!(perc20_transfer_executor_selector(), [0xc7, 0xb9, 0x21, 0xd3]);
279        assert_eq!(wrapped_shield_selector(), [0x04, 0x11, 0xcb, 0xab]);
280        assert_eq!(wrapped_unshield_selector(), [0x53, 0x64, 0x4c, 0x61]);
281        assert_eq!(swap_initiate_selector(), [0x1e, 0x17, 0x9f, 0x2a]);
282        assert_eq!(swap_join_selector(), [0x25, 0x6e, 0x19, 0x50]);
283        assert_eq!(swap_settle_selector(), [0xc7, 0xec, 0xe1, 0x5f]);
284    }
285
286    #[test]
287    fn calldata_prefixes_correct_selector() {
288        let call = dummy_call();
289        assert_eq!(&encode_perc20_transfer_calldata(&call)[..4], &perc20_transfer_selector());
290        assert_eq!(
291            &encode_perc20_transfer_executor_calldata(&[0xEFu8; 20], &call)[..4],
292            &perc20_transfer_executor_selector()
293        );
294        assert_eq!(&encode_wrapped_shield_calldata(1000, &call)[..4], &wrapped_shield_selector());
295        assert_eq!(
296            &encode_wrapped_unshield_calldata(1000, &[0xDEu8; 20], &call)[..4],
297            &wrapped_unshield_selector()
298        );
299    }
300
301    #[test]
302    fn shield_and_transfer_share_privacy_call_tail() {
303        // shield(amount, call) body = uint256 amount ‖ <same PrivacyCall tail as transfer>.
304        let call = dummy_call();
305        let transfer = encode_perc20_transfer_calldata(&call);
306        let shield = encode_wrapped_shield_calldata(1000, &call);
307        // transfer body (after selector) is the offset-encoded PrivacyCall starting at word 0;
308        // shield body has the amount in word 0 then the PrivacyCall offset at word 1. The
309        // encoded PrivacyCall dynamic tail (actions bytes + bindingSig) must be byte-identical.
310        let t_tail = &transfer[4 + 32..]; // skip selector + head offset word
311        let s_tail = &shield[4 + 64..]; // skip selector + amount + offset word
312        assert_eq!(t_tail, s_tail, "PrivacyCall encoding must be reused verbatim");
313    }
314
315    #[test]
316    fn commit_is_deterministic_keccak() {
317        let call = dummy_call();
318        let c1 = privacy_call_commit(&call);
319        let c2 = privacy_call_commit(&call);
320        assert_eq!(c1, c2);
321        // A different binding sig changes the commitment.
322        let mut other = call.clone();
323        other.binding_sig[2] = [0x9u8; 32];
324        assert_ne!(privacy_call_commit(&other), c1);
325    }
326
327    #[test]
328    fn swap_id_matches_abi_encode_layout() {
329        // Deterministic + sensitive to each field.
330        let base = compute_swap_id(&[1u8; 20], &[2u8; 20], &[3u8; 20], &[4u8; 32], &[5u8; 32], &[6u8; 32]);
331        assert_eq!(
332            base,
333            compute_swap_id(&[1u8; 20], &[2u8; 20], &[3u8; 20], &[4u8; 32], &[5u8; 32], &[6u8; 32])
334        );
335        assert_ne!(
336            base,
337            compute_swap_id(&[1u8; 20], &[2u8; 20], &[3u8; 20], &[4u8; 32], &[5u8; 32], &[7u8; 32])
338        );
339    }
340}