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, rkBx, rkBy, salt))` — the
142/// swap id the `SwapCoordinator` derives in `initiateSwap`. The relayer recomputes it locally so
143/// it can issue `joinSwap`/`settle` without waiting to parse the receipt.
144///
145/// `rk_bx`/`rk_by` are the joiner's randomised spend-auth key coords (BE), pre-committed by the
146/// initiator at `initiateSwap` (audit A-1): they are part of the swap id and the join challenge.
147pub fn compute_swap_id(
148    initiator: &[u8; 20],
149    pool_a: &[u8; 20],
150    pool_b: &[u8; 20],
151    htlc_hash: &[u8; 32],
152    commit_a: &[u8; 32],
153    rk_bx: &[u8; 32],
154    rk_by: &[u8; 32],
155    salt: &[u8; 32],
156) -> [u8; 32] {
157    let encoded = encode(&[
158        Token::Address(ethabi::Address::from(*initiator)),
159        Token::Address(ethabi::Address::from(*pool_a)),
160        Token::Address(ethabi::Address::from(*pool_b)),
161        Token::FixedBytes(htlc_hash.to_vec()),
162        Token::FixedBytes(commit_a.to_vec()),
163        Token::Uint(Uint::from_big_endian(rk_bx)),
164        Token::Uint(Uint::from_big_endian(rk_by)),
165        Token::FixedBytes(salt.to_vec()),
166    ]);
167    Keccak256::digest(&encoded).into()
168}
169
170/// `initiateSwap(address,address,bytes32,bytes32,uint256,uint256,uint64,bytes32)` — selector
171/// `0x6db7974d`. `rk_bx`/`rk_by` are the joiner's randomised spend-auth key coords (BE),
172/// pre-committed by the initiator (audit A-1) so only the real counterparty can `joinSwap`.
173pub fn encode_swap_initiate_calldata(
174    pool_a: &[u8; 20],
175    pool_b: &[u8; 20],
176    htlc_hash: &[u8; 32],
177    commit_a: &[u8; 32],
178    rk_bx: &[u8; 32],
179    rk_by: &[u8; 32],
180    deadline: u64,
181    salt: &[u8; 32],
182) -> Vec<u8> {
183    let tokens = vec![
184        Token::Address(ethabi::Address::from(*pool_a)),
185        Token::Address(ethabi::Address::from(*pool_b)),
186        Token::FixedBytes(htlc_hash.to_vec()),
187        Token::FixedBytes(commit_a.to_vec()),
188        Token::Uint(Uint::from_big_endian(rk_bx)),
189        Token::Uint(Uint::from_big_endian(rk_by)),
190        Token::Uint(Uint::from(deadline)),
191        Token::FixedBytes(salt.to_vec()),
192    ];
193    let body = encode(&tokens);
194    with_selector(
195        selector(b"initiateSwap(address,address,bytes32,bytes32,uint256,uint256,uint64,bytes32)"),
196        body,
197    )
198}
199
200/// `joinSwap(bytes32,bytes32,uint256[3])` — selector `0x8bbe821a`.
201/// `rkB` is NOT supplied here — it was committed by the initiator at `initiateSwap` and is read
202/// from storage. `joiner_sig` is the Baby JubJub Schnorr signature under `rkB` over the join
203/// challenge, proving control of the pre-committed key.
204pub fn encode_swap_join_calldata(
205    swap_id: &[u8; 32],
206    commit_b: &[u8; 32],
207    joiner_sig: &[[u8; 32]; 3],
208) -> Vec<u8> {
209    let joiner_sig_token = Token::FixedArray(
210        joiner_sig
211            .iter()
212            .map(|b| Token::Uint(Uint::from_big_endian(b)))
213            .collect(),
214    );
215    let tokens = vec![
216        Token::FixedBytes(swap_id.to_vec()),
217        Token::FixedBytes(commit_b.to_vec()),
218        joiner_sig_token,
219    ];
220    let body = encode(&tokens);
221    with_selector(
222        selector(b"joinSwap(bytes32,bytes32,uint256[3])"),
223        body,
224    )
225}
226
227/// `settle(bytes32,bytes32,(bytes,uint256[3]),(bytes,uint256[3]))` — selector `0xc7ece15f`.
228/// Reveals the HTLC preimage and submits both executor-gated legs atomically.
229pub fn encode_swap_settle_calldata(
230    swap_id: &[u8; 32],
231    secret: &[u8; 32],
232    call_a: &PrivacyCallArgs,
233    call_b: &PrivacyCallArgs,
234) -> Vec<u8> {
235    let tokens = vec![
236        Token::FixedBytes(swap_id.to_vec()),
237        Token::FixedBytes(secret.to_vec()),
238        privacy_call_token(call_a),
239        privacy_call_token(call_b),
240    ];
241    let body = encode(&tokens);
242    with_selector(
243        selector(b"settle(bytes32,bytes32,(bytes,uint256[3]),(bytes,uint256[3]))"),
244        body,
245    )
246}
247
248// ── selectors (handy for tests / dispatch) ───────────────────────────────────
249
250pub fn perc20_transfer_selector() -> [u8; 4] { selector(b"transfer((bytes,uint256[3]))") }
251pub fn perc20_transfer_executor_selector() -> [u8; 4] { selector(b"transfer(address,(bytes,uint256[3]))") }
252pub fn wrapped_shield_selector() -> [u8; 4] { selector(b"shield(uint256,(bytes,uint256[3]))") }
253pub fn wrapped_unshield_selector() -> [u8; 4] { selector(b"unshield(uint256,address,(bytes,uint256[3]))") }
254pub fn swap_initiate_selector() -> [u8; 4] { selector(b"initiateSwap(address,address,bytes32,bytes32,uint256,uint256,uint64,bytes32)") }
255pub fn swap_join_selector() -> [u8; 4] { selector(b"joinSwap(bytes32,bytes32,uint256[3])") }
256pub fn swap_settle_selector() -> [u8; 4] { selector(b"settle(bytes32,bytes32,(bytes,uint256[3]),(bytes,uint256[3]))") }
257
258// Keep `EthEncodeError` reachable for symmetry with the other encoders (none of these
259// fixed-shape encoders can fail today, but callers may want a uniform error type).
260#[allow(dead_code)]
261fn _assert_error_in_scope(_e: EthEncodeError) {}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    fn dummy_action() -> BundleActionArgs {
268        BundleActionArgs {
269            cmx: [1u8; 32],
270            enc_ciphertext: vec![0u8; 580],
271            out_ciphertext: vec![0u8; 80],
272            epk: [2u8; 32],
273            nf_old: [3u8; 32],
274            anchor: [4u8; 32],
275            proof: vec![0xabu8; 256],
276            pub_fields: [[5u8; 32]; 8],
277            spend_auth_sig: [[6u8; 32]; 3],
278        }
279    }
280
281    fn dummy_call() -> PrivacyCallArgs {
282        PrivacyCallArgs { actions: vec![dummy_action()], binding_sig: [[7u8; 32]; 3] }
283    }
284
285    #[test]
286    fn selectors_match_onchain() {
287        assert_eq!(perc20_transfer_selector(), [0xed, 0xa1, 0xa0, 0xac]);
288        assert_eq!(perc20_transfer_executor_selector(), [0xc7, 0xb9, 0x21, 0xd3]);
289        assert_eq!(wrapped_shield_selector(), [0x04, 0x11, 0xcb, 0xab]);
290        assert_eq!(wrapped_unshield_selector(), [0x53, 0x64, 0x4c, 0x61]);
291        assert_eq!(swap_initiate_selector(), [0x6d, 0xb7, 0x97, 0x4d]);
292        assert_eq!(swap_join_selector(), [0x8b, 0xbe, 0x82, 0x1a]);
293        assert_eq!(swap_settle_selector(), [0xc7, 0xec, 0xe1, 0x5f]);
294    }
295
296    #[test]
297    fn calldata_prefixes_correct_selector() {
298        let call = dummy_call();
299        assert_eq!(&encode_perc20_transfer_calldata(&call)[..4], &perc20_transfer_selector());
300        assert_eq!(
301            &encode_perc20_transfer_executor_calldata(&[0xEFu8; 20], &call)[..4],
302            &perc20_transfer_executor_selector()
303        );
304        assert_eq!(&encode_wrapped_shield_calldata(1000, &call)[..4], &wrapped_shield_selector());
305        assert_eq!(
306            &encode_wrapped_unshield_calldata(1000, &[0xDEu8; 20], &call)[..4],
307            &wrapped_unshield_selector()
308        );
309    }
310
311    #[test]
312    fn shield_and_transfer_share_privacy_call_tail() {
313        // shield(amount, call) body = uint256 amount ‖ <same PrivacyCall tail as transfer>.
314        let call = dummy_call();
315        let transfer = encode_perc20_transfer_calldata(&call);
316        let shield = encode_wrapped_shield_calldata(1000, &call);
317        // transfer body (after selector) is the offset-encoded PrivacyCall starting at word 0;
318        // shield body has the amount in word 0 then the PrivacyCall offset at word 1. The
319        // encoded PrivacyCall dynamic tail (actions bytes + bindingSig) must be byte-identical.
320        let t_tail = &transfer[4 + 32..]; // skip selector + head offset word
321        let s_tail = &shield[4 + 64..]; // skip selector + amount + offset word
322        assert_eq!(t_tail, s_tail, "PrivacyCall encoding must be reused verbatim");
323    }
324
325    #[test]
326    fn commit_is_deterministic_keccak() {
327        let call = dummy_call();
328        let c1 = privacy_call_commit(&call);
329        let c2 = privacy_call_commit(&call);
330        assert_eq!(c1, c2);
331        // A different binding sig changes the commitment.
332        let mut other = call.clone();
333        other.binding_sig[2] = [0x9u8; 32];
334        assert_ne!(privacy_call_commit(&other), c1);
335    }
336
337    #[test]
338    fn swap_id_matches_abi_encode_layout() {
339        // Deterministic + sensitive to each field (incl. the pre-committed joiner key rkB).
340        let base = compute_swap_id(&[1u8; 20], &[2u8; 20], &[3u8; 20], &[4u8; 32], &[5u8; 32], &[8u8; 32], &[9u8; 32], &[6u8; 32]);
341        assert_eq!(
342            base,
343            compute_swap_id(&[1u8; 20], &[2u8; 20], &[3u8; 20], &[4u8; 32], &[5u8; 32], &[8u8; 32], &[9u8; 32], &[6u8; 32])
344        );
345        assert_ne!(
346            base,
347            compute_swap_id(&[1u8; 20], &[2u8; 20], &[3u8; 20], &[4u8; 32], &[5u8; 32], &[8u8; 32], &[9u8; 32], &[7u8; 32])
348        );
349        // Changing rkB alone must change the id.
350        assert_ne!(
351            base,
352            compute_swap_id(&[1u8; 20], &[2u8; 20], &[3u8; 20], &[4u8; 32], &[5u8; 32], &[0xAu8; 32], &[9u8; 32], &[6u8; 32])
353        );
354    }
355}