penumbra_sdk_dex/swap/
plaintext.rs

1use anyhow::{anyhow, Error, Result};
2
3use ark_r1cs_std::prelude::*;
4use ark_relations::r1cs::SynthesisError;
5use decaf377::r1cs::FqVar;
6use decaf377::Fq;
7use once_cell::sync::Lazy;
8use penumbra_sdk_fee::Fee;
9use penumbra_sdk_proto::{
10    core::keys::v1 as pb_keys, penumbra::core::component::dex::v1 as pb, DomainType,
11};
12use penumbra_sdk_tct::StateCommitment;
13use poseidon377::{hash_1, hash_4, hash_7};
14use rand_core::{CryptoRng, RngCore};
15
16use decaf377_ka as ka;
17use penumbra_sdk_asset::{asset, Value, ValueVar};
18use penumbra_sdk_keys::{keys::OutgoingViewingKey, Address, AddressVar, PayloadKey};
19use penumbra_sdk_num::{Amount, AmountVar};
20use penumbra_sdk_shielded_pool::{Note, Rseed};
21use penumbra_sdk_tct::r1cs::StateCommitmentVar;
22
23use crate::{BatchSwapOutputData, TradingPair, TradingPairVar};
24
25use super::{SwapCiphertext, SwapPayload, DOMAIN_SEPARATOR, SWAP_CIPHERTEXT_BYTES, SWAP_LEN_BYTES};
26
27#[derive(Clone, Debug, PartialEq, Eq)]
28pub struct SwapPlaintext {
29    // Trading pair for the swap
30    pub trading_pair: TradingPair,
31    // Input amount of asset 1
32    pub delta_1_i: Amount,
33    // Input amount of asset 2
34    pub delta_2_i: Amount,
35    // Prepaid fee to claim the swap
36    pub claim_fee: Fee,
37    // Address to receive the SwapClaim outputs
38    pub claim_address: Address,
39    // Swap rseed
40    pub rseed: Rseed,
41}
42
43pub static OUTPUT_1_BLINDING_DOMAIN_SEPARATOR: Lazy<Fq> = Lazy::new(|| {
44    Fq::from_le_bytes_mod_order(
45        blake2b_simd::blake2b(b"penumbra.swapclaim.output1.blinding").as_bytes(),
46    )
47});
48pub static OUTPUT_2_BLINDING_DOMAIN_SEPARATOR: Lazy<Fq> = Lazy::new(|| {
49    Fq::from_le_bytes_mod_order(
50        blake2b_simd::blake2b(b"penumbra.swapclaim.output2.blinding").as_bytes(),
51    )
52});
53
54impl SwapPlaintext {
55    pub fn output_rseeds(&self) -> (Rseed, Rseed) {
56        let fq_rseed = Fq::from_le_bytes_mod_order(&self.rseed.to_bytes()[..]);
57        let rseed_1_hash = hash_1(&OUTPUT_1_BLINDING_DOMAIN_SEPARATOR, fq_rseed);
58        let rseed_2_hash = hash_1(&OUTPUT_2_BLINDING_DOMAIN_SEPARATOR, fq_rseed);
59        (
60            Rseed(rseed_1_hash.to_bytes()),
61            Rseed(rseed_2_hash.to_bytes()),
62        )
63    }
64
65    pub fn output_notes(&self, batch_data: &BatchSwapOutputData) -> (Note, Note) {
66        let (output_1_rseed, output_2_rseed) = self.output_rseeds();
67
68        let (lambda_1_i, lambda_2_i) =
69            batch_data.pro_rata_outputs((self.delta_1_i, self.delta_2_i));
70
71        let output_1_note = Note::from_parts(
72            self.claim_address.clone(),
73            Value {
74                amount: lambda_1_i,
75                asset_id: self.trading_pair.asset_1(),
76            },
77            output_1_rseed,
78        )
79        .expect("claim address is valid");
80
81        let output_2_note = Note::from_parts(
82            self.claim_address.clone(),
83            Value {
84                amount: lambda_2_i,
85                asset_id: self.trading_pair.asset_2(),
86            },
87            output_2_rseed,
88        )
89        .expect("claim address is valid");
90
91        (output_1_note, output_2_note)
92    }
93
94    // Constructs the unique asset ID for a swap as a poseidon hash of the input data for the swap.
95    //
96    // https://protocol.penumbra.zone/main/zswap/swap.html#swap-actions
97    pub fn swap_commitment(&self) -> StateCommitment {
98        let inner = hash_7(
99            &DOMAIN_SEPARATOR,
100            (
101                Fq::from_le_bytes_mod_order(&self.rseed.to_bytes()[..]),
102                self.claim_fee.0.amount.into(),
103                self.claim_fee.0.asset_id.0,
104                self.claim_address
105                    .diversified_generator()
106                    .vartime_compress_to_field(),
107                *self.claim_address.transmission_key_s(),
108                Fq::from_le_bytes_mod_order(&self.claim_address.clue_key().0[..]),
109                hash_4(
110                    &DOMAIN_SEPARATOR,
111                    (
112                        self.trading_pair.asset_1().0,
113                        self.trading_pair.asset_2().0,
114                        self.delta_1_i.into(),
115                        self.delta_2_i.into(),
116                    ),
117                ),
118            ),
119        );
120
121        StateCommitment(inner)
122    }
123
124    pub fn diversified_generator(&self) -> &decaf377::Element {
125        self.claim_address.diversified_generator()
126    }
127
128    pub fn transmission_key(&self) -> &ka::Public {
129        self.claim_address.transmission_key()
130    }
131
132    pub fn encrypt(&self, ovk: &OutgoingViewingKey) -> SwapPayload {
133        let commitment = self.swap_commitment();
134        let key = PayloadKey::derive_swap(ovk, commitment);
135        let swap_plaintext: [u8; SWAP_LEN_BYTES] = self.into();
136        let encryption_result = key.encrypt_swap(swap_plaintext.to_vec());
137
138        let ciphertext: [u8; SWAP_CIPHERTEXT_BYTES] = encryption_result
139            .try_into()
140            .expect("swap encryption result fits in ciphertext len");
141
142        SwapPayload {
143            encrypted_swap: SwapCiphertext(ciphertext),
144            commitment,
145        }
146    }
147
148    pub fn delta_1_value(&self) -> Value {
149        Value {
150            amount: self.delta_1_i,
151            asset_id: self.trading_pair.asset_1,
152        }
153    }
154
155    pub fn delta_2_value(&self) -> Value {
156        Value {
157            amount: self.delta_2_i,
158            asset_id: self.trading_pair.asset_2,
159        }
160    }
161
162    pub fn new<R: RngCore + CryptoRng>(
163        rng: &mut R,
164        trading_pair: TradingPair,
165        delta_1_i: Amount,
166        delta_2_i: Amount,
167        claim_fee: Fee,
168        claim_address: Address,
169    ) -> SwapPlaintext {
170        let rseed = Rseed::generate(rng);
171
172        Self {
173            trading_pair,
174            delta_1_i,
175            delta_2_i,
176            claim_fee,
177            claim_address,
178            rseed,
179        }
180    }
181}
182
183pub struct SwapPlaintextVar {
184    pub claim_fee: ValueVar,
185    pub delta_1_i: AmountVar,
186    pub trading_pair: TradingPairVar,
187    pub delta_2_i: AmountVar,
188    pub claim_address: AddressVar,
189    pub rseed: FqVar,
190}
191
192impl SwapPlaintextVar {
193    pub fn delta_1_value(&self) -> ValueVar {
194        ValueVar {
195            amount: self.delta_1_i.clone(),
196            asset_id: self.trading_pair.asset_1.clone(),
197        }
198    }
199
200    pub fn delta_2_value(&self) -> ValueVar {
201        ValueVar {
202            amount: self.delta_2_i.clone(),
203            asset_id: self.trading_pair.asset_2.clone(),
204        }
205    }
206
207    pub fn commit(&self) -> Result<StateCommitmentVar, SynthesisError> {
208        // Access constraint system.
209        let cs = self.delta_1_i.amount.cs();
210
211        let domain_sep = FqVar::new_constant(cs.clone(), *DOMAIN_SEPARATOR)?;
212        let compressed_g_d = self
213            .claim_address
214            .diversified_generator()
215            .compress_to_field()?;
216
217        let inner_hash4 = poseidon377::r1cs::hash_4(
218            cs.clone(),
219            &domain_sep,
220            (
221                self.trading_pair.asset_1.asset_id.clone(),
222                self.trading_pair.asset_2.asset_id.clone(),
223                self.delta_1_i.amount.clone(),
224                self.delta_2_i.amount.clone(),
225            ),
226        )?;
227
228        let inner = poseidon377::r1cs::hash_7(
229            cs,
230            &domain_sep,
231            (
232                self.rseed.clone(),
233                self.claim_fee.amount.amount.clone(),
234                self.claim_fee.asset_id.asset_id.clone(),
235                compressed_g_d,
236                self.claim_address.transmission_key().compress_to_field()?,
237                self.claim_address.clue_key(),
238                inner_hash4,
239            ),
240        )?;
241
242        Ok(StateCommitmentVar { inner })
243    }
244}
245
246impl AllocVar<SwapPlaintext, Fq> for SwapPlaintextVar {
247    fn new_variable<T: std::borrow::Borrow<SwapPlaintext>>(
248        cs: impl Into<ark_relations::r1cs::Namespace<Fq>>,
249        f: impl FnOnce() -> Result<T, SynthesisError>,
250        mode: ark_r1cs_std::prelude::AllocationMode,
251    ) -> Result<Self, SynthesisError> {
252        let ns = cs.into();
253        let cs = ns.cs();
254        let swap_plaintext = f()?.borrow().clone();
255        let claim_fee =
256            ValueVar::new_variable(cs.clone(), || Ok(swap_plaintext.claim_fee.0), mode)?;
257        let delta_1_i = AmountVar::new_variable(cs.clone(), || Ok(swap_plaintext.delta_1_i), mode)?;
258
259        // Note: We currently use `TradingPairVar::new_variable_unchecked` as the only
260        // place we use the trading pair is when computing the swap commitment. A malicious
261        // prover is unable to switch the direction of the canonical trading pair as the
262        // swap commitment integrity check would be invalid.
263        let trading_pair = TradingPairVar::new_variable_unchecked(
264            cs.clone(),
265            || Ok(swap_plaintext.trading_pair),
266            mode,
267        )?;
268        let delta_2_i = AmountVar::new_variable(cs.clone(), || Ok(swap_plaintext.delta_2_i), mode)?;
269        let claim_address =
270            AddressVar::new_variable(cs.clone(), || Ok(swap_plaintext.claim_address), mode)?;
271        let rseed = FqVar::new_variable(
272            cs,
273            || {
274                Ok(Fq::from_le_bytes_mod_order(
275                    &swap_plaintext.rseed.to_bytes()[..],
276                ))
277            },
278            mode,
279        )?;
280        Ok(Self {
281            claim_fee,
282            delta_1_i,
283            trading_pair,
284            delta_2_i,
285            claim_address,
286            rseed,
287        })
288    }
289}
290
291impl DomainType for SwapPlaintext {
292    type Proto = pb::SwapPlaintext;
293}
294
295impl TryFrom<pb::SwapPlaintext> for SwapPlaintext {
296    type Error = anyhow::Error;
297    fn try_from(plaintext: pb::SwapPlaintext) -> anyhow::Result<Self> {
298        Ok(Self {
299            delta_1_i: plaintext
300                .delta_1_i
301                .ok_or_else(|| anyhow!("missing delta_1_i"))?
302                .try_into()?,
303            delta_2_i: plaintext
304                .delta_2_i
305                .ok_or_else(|| anyhow!("missing delta_2_i"))?
306                .try_into()?,
307            claim_address: plaintext
308                .claim_address
309                .ok_or_else(|| anyhow::anyhow!("missing SwapPlaintext claim address"))?
310                .try_into()
311                .map_err(|_| anyhow::anyhow!("invalid claim address in SwapPlaintext"))?,
312            claim_fee: plaintext
313                .claim_fee
314                .ok_or_else(|| anyhow::anyhow!("missing SwapPlaintext claim_fee"))?
315                .try_into()?,
316            trading_pair: plaintext
317                .trading_pair
318                .ok_or_else(|| anyhow::anyhow!("missing trading pair in SwapPlaintext"))?
319                .try_into()?,
320            rseed: Rseed(plaintext.rseed.as_slice().try_into()?),
321        })
322    }
323}
324
325impl From<SwapPlaintext> for pb::SwapPlaintext {
326    fn from(plaintext: SwapPlaintext) -> Self {
327        Self {
328            delta_1_i: Some(plaintext.delta_1_i.into()),
329            delta_2_i: Some(plaintext.delta_2_i.into()),
330            claim_fee: Some(plaintext.claim_fee.into()),
331            claim_address: Some(plaintext.claim_address.into()),
332            trading_pair: Some(plaintext.trading_pair.into()),
333            rseed: plaintext.rseed.to_bytes().to_vec(),
334        }
335    }
336}
337
338impl From<&SwapPlaintext> for [u8; SWAP_LEN_BYTES] {
339    fn from(swap: &SwapPlaintext) -> [u8; SWAP_LEN_BYTES] {
340        let mut bytes = [0u8; SWAP_LEN_BYTES];
341        bytes[0..64].copy_from_slice(&swap.trading_pair.to_bytes());
342        bytes[64..80].copy_from_slice(&swap.delta_1_i.to_le_bytes());
343        bytes[80..96].copy_from_slice(&swap.delta_2_i.to_le_bytes());
344        bytes[96..112].copy_from_slice(&swap.claim_fee.0.amount.to_le_bytes());
345        bytes[112..144].copy_from_slice(&swap.claim_fee.0.asset_id.to_bytes());
346        let pb_address = pb_keys::Address::from(swap.claim_address.clone());
347        bytes[144..224].copy_from_slice(&pb_address.inner);
348        bytes[224..256].copy_from_slice(&swap.rseed.to_bytes());
349        bytes
350    }
351}
352
353impl From<SwapPlaintext> for [u8; SWAP_LEN_BYTES] {
354    fn from(swap: SwapPlaintext) -> [u8; SWAP_LEN_BYTES] {
355        (&swap).into()
356    }
357}
358
359impl TryFrom<&[u8]> for SwapPlaintext {
360    type Error = Error;
361
362    fn try_from(bytes: &[u8]) -> Result<Self, Self::Error> {
363        if bytes.len() != SWAP_LEN_BYTES {
364            anyhow::bail!("incorrect length for serialized swap plaintext");
365        }
366
367        let tp_bytes: [u8; 64] = bytes[0..64]
368            .try_into()
369            .map_err(|_| anyhow!("error fetching trading pair bytes"))?;
370        let delta_1_bytes: [u8; 16] = bytes[64..80]
371            .try_into()
372            .map_err(|_| anyhow!("error fetching delta1 bytes"))?;
373        let delta_2_bytes: [u8; 16] = bytes[80..96]
374            .try_into()
375            .map_err(|_| anyhow!("error fetching delta2 bytes"))?;
376        let fee_amount_bytes: [u8; 16] = bytes[96..112]
377            .try_into()
378            .map_err(|_| anyhow!("error fetching fee amount bytes"))?;
379        let fee_asset_id_bytes: [u8; 32] = bytes[112..144]
380            .try_into()
381            .map_err(|_| anyhow!("error fetching fee asset ID bytes"))?;
382        let address_bytes: [u8; 80] = bytes[144..224]
383            .try_into()
384            .map_err(|_| anyhow!("error fetching address bytes"))?;
385        let pb_address = pb_keys::Address {
386            inner: address_bytes.to_vec(),
387            alt_bech32m: String::new(),
388        };
389        let rseed: [u8; 32] = bytes[224..256]
390            .try_into()
391            .map_err(|_| anyhow!("error fetching rseed bytes"))?;
392
393        Ok(SwapPlaintext {
394            trading_pair: tp_bytes
395                .try_into()
396                .map_err(|_| anyhow!("error deserializing trading pair"))?,
397            delta_1_i: Amount::from_le_bytes(delta_1_bytes),
398            delta_2_i: Amount::from_le_bytes(delta_2_bytes),
399            claim_fee: Fee(Value {
400                amount: Amount::from_le_bytes(fee_amount_bytes),
401                asset_id: asset::Id::try_from(fee_asset_id_bytes)?,
402            }),
403            claim_address: pb_address.try_into()?,
404            rseed: Rseed(rseed),
405        })
406    }
407}
408
409impl TryFrom<[u8; SWAP_LEN_BYTES]> for SwapPlaintext {
410    type Error = Error;
411
412    fn try_from(bytes: [u8; SWAP_LEN_BYTES]) -> Result<SwapPlaintext, Self::Error> {
413        (&bytes[..]).try_into()
414    }
415}
416
417#[cfg(test)]
418mod tests {
419
420    use rand_core::OsRng;
421
422    use super::*;
423    use penumbra_sdk_asset::{asset, Value};
424    use penumbra_sdk_keys::keys::{Bip44Path, SeedPhrase, SpendKey};
425
426    #[test]
427    /// Check the swap plaintext can be encrypted and decrypted with the OVK.
428    fn swap_encryption_and_decryption() {
429        let mut rng = OsRng;
430
431        let seed_phrase = SeedPhrase::generate(rng);
432        let sk = SpendKey::from_seed_phrase_bip44(seed_phrase, &Bip44Path::new(0));
433        let fvk = sk.full_viewing_key();
434        let ivk = fvk.incoming();
435        let ovk = fvk.outgoing();
436        let (dest, _dtk_d) = ivk.payment_address(0u32.into());
437        let trading_pair = TradingPair::new(
438            asset::Cache::with_known_assets()
439                .get_unit("upenumbra")
440                .unwrap()
441                .id(),
442            asset::Cache::with_known_assets()
443                .get_unit("nala")
444                .unwrap()
445                .id(),
446        );
447
448        let swap = SwapPlaintext::new(
449            &mut rng,
450            trading_pair,
451            100000u64.into(),
452            1u64.into(),
453            Fee(Value {
454                amount: 3u64.into(),
455                asset_id: asset::Cache::with_known_assets()
456                    .get_unit("upenumbra")
457                    .unwrap()
458                    .id(),
459            }),
460            dest,
461        );
462
463        let ciphertext = swap.encrypt(ovk).encrypted_swap;
464        let plaintext = SwapCiphertext::decrypt(&ciphertext, ovk, swap.swap_commitment())
465            .expect("can decrypt swap");
466
467        assert_eq!(plaintext, swap);
468    }
469}