penumbra_sdk_dex/swap/
proof.rs

1use anyhow::{Context, Result};
2use ark_ff::ToConstraintField;
3use ark_groth16::{
4    r1cs_to_qap::LibsnarkReduction, Groth16, PreparedVerifyingKey, Proof, ProvingKey,
5};
6use ark_r1cs_std::prelude::*;
7use ark_relations::r1cs::{ConstraintSynthesizer, ConstraintSystemRef};
8use ark_serialize::{CanonicalDeserialize, CanonicalSerialize};
9use ark_snark::SNARK;
10use decaf377::Bls12_377;
11use decaf377::{Fq, Fr};
12use decaf377_fmd as fmd;
13use decaf377_ka as ka;
14use penumbra_sdk_fee::Fee;
15use penumbra_sdk_proto::{core::component::dex::v1 as pb, DomainType};
16use penumbra_sdk_tct as tct;
17use penumbra_sdk_tct::r1cs::StateCommitmentVar;
18
19use penumbra_sdk_asset::{
20    asset,
21    balance::{self, commitment::BalanceCommitmentVar, BalanceVar},
22    Value,
23};
24use penumbra_sdk_keys::{keys::Diversifier, Address};
25use penumbra_sdk_shielded_pool::Rseed;
26
27use crate::{
28    swap::{SwapPlaintext, SwapPlaintextVar},
29    TradingPair,
30};
31
32use penumbra_sdk_proof_params::{DummyWitness, GROTH16_PROOF_LENGTH_BYTES};
33
34/// The public inputs to a [`SwapProof`].
35#[derive(Clone, Debug)]
36pub struct SwapProofPublic {
37    /// A commitment to the balance of this transaction.
38    pub balance_commitment: balance::Commitment,
39    /// A commitment to the swap.
40    pub swap_commitment: tct::StateCommitment,
41    /// A commitment to the fee that was paid.
42    pub fee_commitment: balance::Commitment,
43}
44
45/// The private inputs to a [`SwapProof`].
46#[derive(Clone, Debug)]
47pub struct SwapProofPrivate {
48    /// A randomizer to make the commitment to the fee hiding.
49    pub fee_blinding: Fr,
50    /// All information about the swap.
51    pub swap_plaintext: SwapPlaintext,
52}
53
54#[cfg(test)]
55fn check_satisfaction(public: &SwapProofPublic, private: &SwapProofPrivate) -> Result<()> {
56    use penumbra_sdk_asset::Balance;
57
58    let swap_commitment = private.swap_plaintext.swap_commitment();
59    if swap_commitment != public.swap_commitment {
60        anyhow::bail!("swap commitment did not match public input");
61    }
62
63    let fee_balance = -Balance::from(private.swap_plaintext.claim_fee.0);
64    let fee_commitment = fee_balance.commit(private.fee_blinding);
65    if fee_commitment != public.fee_commitment {
66        anyhow::bail!("fee commitment did not match public input");
67    }
68
69    let balance_1 = -Balance::from(private.swap_plaintext.delta_1_value());
70    let balance_2 = -Balance::from(private.swap_plaintext.delta_2_value());
71    let transparent_blinding = Fr::from(0u64);
72    let balance_1_commit = balance_1.commit(transparent_blinding);
73    let balance_2_commit = balance_2.commit(transparent_blinding);
74    let transparent_balance_commitment = balance_1_commit + balance_2_commit;
75    let total_balance_commitment = transparent_balance_commitment + fee_commitment;
76    if total_balance_commitment != public.balance_commitment {
77        anyhow::bail!("balance commitment did not match public input");
78    }
79
80    Ok(())
81}
82
83#[cfg(test)]
84fn check_circuit_satisfaction(public: SwapProofPublic, private: SwapProofPrivate) -> Result<()> {
85    use ark_relations::r1cs::{self, ConstraintSystem};
86
87    let cs: ConstraintSystemRef<_> = ConstraintSystem::new_ref();
88    let circuit = SwapCircuit { public, private };
89    cs.set_optimization_goal(r1cs::OptimizationGoal::Constraints);
90    circuit
91        .generate_constraints(cs.clone())
92        .expect("can generate constraints from circuit");
93    cs.finalize();
94    if !cs.is_satisfied()? {
95        anyhow::bail!("constraints are not satisfied");
96    }
97    Ok(())
98}
99
100pub struct SwapCircuit {
101    public: SwapProofPublic,
102    private: SwapProofPrivate,
103}
104
105impl ConstraintSynthesizer<Fq> for SwapCircuit {
106    fn generate_constraints(self, cs: ConstraintSystemRef<Fq>) -> ark_relations::r1cs::Result<()> {
107        // Witnesses
108        let swap_plaintext_var =
109            SwapPlaintextVar::new_witness(cs.clone(), || Ok(self.private.swap_plaintext.clone()))?;
110        let fee_blinding_var =
111            UInt8::new_witness_vec(cs.clone(), &self.private.fee_blinding.to_bytes())?;
112
113        // Inputs
114        let claimed_balance_commitment =
115            BalanceCommitmentVar::new_input(cs.clone(), || Ok(self.public.balance_commitment))?;
116        let claimed_swap_commitment =
117            StateCommitmentVar::new_input(cs.clone(), || Ok(self.public.swap_commitment))?;
118        let claimed_fee_commitment =
119            BalanceCommitmentVar::new_input(cs, || Ok(self.public.fee_commitment))?;
120
121        // Swap commitment integrity check
122        let swap_commitment = swap_plaintext_var.commit()?;
123        claimed_swap_commitment.enforce_equal(&swap_commitment)?;
124
125        // Fee commitment integrity check
126        let fee_balance = BalanceVar::from_negative_value_var(swap_plaintext_var.claim_fee.clone());
127        let fee_commitment = fee_balance.commit(fee_blinding_var)?;
128        claimed_fee_commitment.enforce_equal(&fee_commitment)?;
129
130        // Reconstruct swap action balance commitment
131        let transparent_blinding_var = UInt8::constant_vec(&[0u8; 32]);
132        let balance_1 = BalanceVar::from_negative_value_var(swap_plaintext_var.delta_1_value());
133        let balance_2 = BalanceVar::from_negative_value_var(swap_plaintext_var.delta_2_value());
134        let balance_1_commit = balance_1.commit(transparent_blinding_var.clone())?;
135        let balance_2_commit = balance_2.commit(transparent_blinding_var)?;
136        let transparent_balance_commitment = balance_1_commit + balance_2_commit;
137        let total_balance_commitment = transparent_balance_commitment + fee_commitment;
138
139        // Balance commitment integrity check
140        claimed_balance_commitment.enforce_equal(&total_balance_commitment)?;
141
142        Ok(())
143    }
144}
145
146impl DummyWitness for SwapCircuit {
147    fn with_dummy_witness() -> Self {
148        let a = asset::Cache::with_known_assets()
149            .get_unit("upenumbra")
150            .expect("upenumbra asset exists");
151        let b = asset::Cache::with_known_assets()
152            .get_unit("nala")
153            .expect("nala asset exists");
154        let trading_pair = TradingPair::new(a.id(), b.id());
155        let diversifier_bytes = [1u8; 16];
156        let pk_d_bytes = decaf377::Element::GENERATOR.vartime_compress().0;
157        let clue_key_bytes = [1; 32];
158        let diversifier = Diversifier(diversifier_bytes);
159        let address = Address::from_components(
160            diversifier,
161            ka::Public(pk_d_bytes),
162            fmd::ClueKey(clue_key_bytes),
163        )
164        .expect("generated 1 address");
165        let swap_plaintext = SwapPlaintext {
166            trading_pair,
167            delta_1_i: 100000u64.into(),
168            delta_2_i: 1u64.into(),
169            claim_fee: Fee(Value {
170                amount: 3u64.into(),
171                asset_id: asset::Cache::with_known_assets()
172                    .get_unit("upenumbra")
173                    .expect("upenumbra asset exists")
174                    .id(),
175            }),
176            claim_address: address,
177            rseed: Rseed([1u8; 32]),
178        };
179
180        Self {
181            private: SwapProofPrivate {
182                swap_plaintext: swap_plaintext.clone(),
183                fee_blinding: Fr::from(1u64),
184            },
185            public: SwapProofPublic {
186                swap_commitment: swap_plaintext.swap_commitment(),
187                fee_commitment: balance::Commitment(decaf377::Element::GENERATOR),
188                balance_commitment: balance::Commitment(decaf377::Element::GENERATOR),
189            },
190        }
191    }
192}
193
194#[derive(Clone, Debug)]
195pub struct SwapProof([u8; GROTH16_PROOF_LENGTH_BYTES]);
196
197impl SwapProof {
198    #![allow(clippy::too_many_arguments)]
199    pub fn prove(
200        blinding_r: Fq,
201        blinding_s: Fq,
202        pk: &ProvingKey<Bls12_377>,
203        public: SwapProofPublic,
204        private: SwapProofPrivate,
205    ) -> anyhow::Result<Self> {
206        let circuit = SwapCircuit { public, private };
207        let proof = Groth16::<Bls12_377, LibsnarkReduction>::create_proof_with_reduction(
208            circuit, pk, blinding_r, blinding_s,
209        )
210        .map_err(|err| anyhow::anyhow!(err))?;
211        let mut proof_bytes = [0u8; GROTH16_PROOF_LENGTH_BYTES];
212        Proof::serialize_compressed(&proof, &mut proof_bytes[..]).expect("can serialize Proof");
213        Ok(Self(proof_bytes))
214    }
215
216    /// Called to verify the proof using the provided public inputs.
217    ///
218    /// The public inputs are:
219    /// * balance commitment,
220    /// * swap commitment,
221    /// * fee commimtment,
222    ///
223    // Commented out, but this may be useful when debugging proof verification failures,
224    // to check that the proof data and verification keys are consistent.
225    //#[tracing::instrument(skip(self, vk), fields(self = ?base64::encode(&self.clone().encode_to_vec()), vk = ?vk.debug_id()))]
226    #[tracing::instrument(skip(self, vk))]
227    pub fn verify(
228        &self,
229        vk: &PreparedVerifyingKey<Bls12_377>,
230        public: SwapProofPublic,
231    ) -> anyhow::Result<()> {
232        let proof =
233            Proof::deserialize_compressed_unchecked(&self.0[..]).map_err(|e| anyhow::anyhow!(e))?;
234
235        let mut public_inputs = Vec::new();
236        public_inputs.extend(
237            public
238                .balance_commitment
239                .0
240                .to_field_elements()
241                .context("balance_commitment should be a Bls12-377 field member")?,
242        );
243        public_inputs.extend(
244            public
245                .swap_commitment
246                .0
247                .to_field_elements()
248                .context("swap_commitment should be a Bls12-377 field member")?,
249        );
250        public_inputs.extend(
251            public
252                .fee_commitment
253                .0
254                .to_field_elements()
255                .context("fee_commitment should be a Bls12-377 field member")?,
256        );
257
258        tracing::trace!(?public_inputs);
259        let start = std::time::Instant::now();
260        let proof_result = Groth16::<Bls12_377, LibsnarkReduction>::verify_with_processed_vk(
261            vk,
262            public_inputs.as_slice(),
263            &proof,
264        )
265        .map_err(|err| anyhow::anyhow!(err))?;
266        tracing::debug!(?proof_result, elapsed = ?start.elapsed());
267        proof_result
268            .then_some(())
269            .ok_or_else(|| anyhow::anyhow!("a swap proof did not verify"))
270    }
271}
272
273impl DomainType for SwapProof {
274    type Proto = pb::ZkSwapProof;
275}
276
277impl From<SwapProof> for pb::ZkSwapProof {
278    fn from(proof: SwapProof) -> Self {
279        pb::ZkSwapProof {
280            inner: proof.0.to_vec(),
281        }
282    }
283}
284
285impl TryFrom<pb::ZkSwapProof> for SwapProof {
286    type Error = anyhow::Error;
287
288    fn try_from(proto: pb::ZkSwapProof) -> Result<Self, Self::Error> {
289        Ok(SwapProof(proto.inner[..].try_into()?))
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296    use penumbra_sdk_asset::{Balance, Value};
297    use penumbra_sdk_keys::keys::{Bip44Path, SeedPhrase, SpendKey};
298    use penumbra_sdk_num::Amount;
299    use proptest::prelude::*;
300
301    fn fr_strategy() -> BoxedStrategy<Fr> {
302        any::<[u8; 32]>()
303            .prop_map(|bytes| Fr::from_le_bytes_mod_order(&bytes[..]))
304            .boxed()
305    }
306
307    prop_compose! {
308        fn arb_valid_swap_statement()(fee_blinding in fr_strategy(), address_index in any::<u32>(), value1_amount in any::<u64>(), seed_phrase_randomness in any::<[u8; 32]>(), rseed_randomness in any::<[u8; 32]>()) -> (SwapProofPublic, SwapProofPrivate) {
309            let seed_phrase = SeedPhrase::from_randomness(&seed_phrase_randomness);
310            let sk_trader = SpendKey::from_seed_phrase_bip44(seed_phrase, &Bip44Path::new(0));
311            let fvk_trader = sk_trader.full_viewing_key();
312            let ivk_trader = fvk_trader.incoming();
313            let (claim_address, _dtk_d) = ivk_trader.payment_address(address_index.into());
314
315            let gm = asset::Cache::with_known_assets().get_unit("gm").unwrap();
316            let gn = asset::Cache::with_known_assets().get_unit("gn").unwrap();
317            let trading_pair = TradingPair::new(gm.id(), gn.id());
318
319            let delta_1_i = Amount::from(value1_amount);
320            let delta_2_i = Amount::from(0u64);
321            let fee = Fee::default();
322
323            let rseed = Rseed(rseed_randomness);
324            let swap_plaintext = SwapPlaintext {
325                trading_pair,
326                delta_1_i,
327                delta_2_i,
328                claim_fee: fee,
329                claim_address,
330                rseed,
331            };
332            let fee_commitment = swap_plaintext.claim_fee.commit(fee_blinding);
333            let swap_commitment = swap_plaintext.swap_commitment();
334
335            let value_1 = Value {
336                amount: swap_plaintext.delta_1_i,
337                asset_id: swap_plaintext.trading_pair.asset_1(),
338            };
339            let value_2 = Value {
340                amount: swap_plaintext.delta_2_i,
341                asset_id:  swap_plaintext.trading_pair.asset_2(),
342            };
343            let value_fee = Value {
344                amount: swap_plaintext.claim_fee.amount(),
345                asset_id: swap_plaintext.claim_fee.asset_id(),
346            };
347            let mut balance = Balance::default();
348            balance -= value_1;
349            balance -= value_2;
350            balance -= value_fee;
351            let balance_commitment = balance.commit(fee_blinding);
352
353            let public = SwapProofPublic { balance_commitment, swap_commitment, fee_commitment };
354            let private = SwapProofPrivate { fee_blinding, swap_plaintext };
355
356            (public, private)
357        }
358    }
359
360    proptest! {
361        #[test]
362        fn swap_proof_happy_path((public, private) in arb_valid_swap_statement()) {
363            assert!(check_satisfaction(&public, &private).is_ok());
364            assert!(check_circuit_satisfaction(public, private).is_ok());
365        }
366    }
367
368    prop_compose! {
369        // This strategy generates a swap statement with an invalid fee blinding factor.
370        fn arb_invalid_swap_statement_fee_commitment()(fee_blinding in fr_strategy(), invalid_fee_blinding in fr_strategy(), address_index in any::<u32>(), value1_amount in any::<u64>(), seed_phrase_randomness in any::<[u8; 32]>(), rseed_randomness in any::<[u8; 32]>()) -> (SwapProofPublic, SwapProofPrivate) {
371            let seed_phrase = SeedPhrase::from_randomness(&seed_phrase_randomness);
372            let sk_trader = SpendKey::from_seed_phrase_bip44(seed_phrase, &Bip44Path::new(0));
373            let fvk_trader = sk_trader.full_viewing_key();
374            let ivk_trader = fvk_trader.incoming();
375            let (claim_address, _dtk_d) = ivk_trader.payment_address(address_index.into());
376
377            let gm = asset::Cache::with_known_assets().get_unit("gm").unwrap();
378            let gn = asset::Cache::with_known_assets().get_unit("gn").unwrap();
379            let trading_pair = TradingPair::new(gm.id(), gn.id());
380
381            let delta_1_i = Amount::from(value1_amount);
382            let delta_2_i = Amount::from(0u64);
383            let fee = Fee::default();
384
385            let rseed = Rseed(rseed_randomness);
386            let swap_plaintext = SwapPlaintext {
387                trading_pair,
388                delta_1_i,
389                delta_2_i,
390                claim_fee: fee,
391                claim_address,
392                rseed,
393            };
394            let swap_commitment = swap_plaintext.swap_commitment();
395
396            let value_1 = Value {
397                amount: swap_plaintext.delta_1_i,
398                asset_id: swap_plaintext.trading_pair.asset_1(),
399            };
400            let value_2 = Value {
401                amount: swap_plaintext.delta_2_i,
402                asset_id:  swap_plaintext.trading_pair.asset_2(),
403            };
404            let value_fee = Value {
405                amount: swap_plaintext.claim_fee.amount(),
406                asset_id: swap_plaintext.claim_fee.asset_id(),
407            };
408            let mut balance = Balance::default();
409            balance -= value_1;
410            balance -= value_2;
411            balance -= value_fee;
412            let balance_commitment = balance.commit(fee_blinding);
413
414            let invalid_fee_commitment = swap_plaintext.claim_fee.commit(invalid_fee_blinding);
415
416            let public = SwapProofPublic { balance_commitment, swap_commitment, fee_commitment: invalid_fee_commitment };
417            let private = SwapProofPrivate { fee_blinding, swap_plaintext };
418
419            (public, private)
420        }
421    }
422
423    proptest! {
424        #[test]
425        fn swap_proof_invalid_fee_commitment((public, private) in arb_invalid_swap_statement_fee_commitment()) {
426            assert!(check_satisfaction(&public, &private).is_err());
427            assert!(check_circuit_satisfaction(public, private).is_err());
428        }
429    }
430}