penumbra_sdk_shielded_pool/
convert.rs

1use anyhow::{anyhow, Result};
2use ark_ff::ToConstraintField;
3use ark_groth16::{
4    r1cs_to_qap::LibsnarkReduction, Groth16, PreparedVerifyingKey, Proof, ProvingKey,
5};
6use ark_relations::r1cs;
7use ark_serialize::{CanonicalDeserialize, CanonicalSerialize};
8use ark_snark::SNARK;
9use base64::prelude::*;
10use decaf377::{Bls12_377, Fq, Fr};
11use penumbra_sdk_asset::{
12    asset::{self, AssetIdVar},
13    balance::{self, commitment::BalanceCommitmentVar, BalanceVar},
14    Balance, Value, ValueVar, STAKING_TOKEN_ASSET_ID,
15};
16use penumbra_sdk_num::{
17    fixpoint::{U128x128, U128x128Var},
18    Amount, AmountVar,
19};
20use penumbra_sdk_proof_params::{DummyWitness, VerifyingKeyExt, GROTH16_PROOF_LENGTH_BYTES};
21
22/// The public input for a [`ConvertProof`].
23#[derive(Clone, Debug)]
24pub struct ConvertProofPublic {
25    /// The source asset being consumed.
26    pub from: asset::Id,
27    /// The destination asset being produced.
28    pub to: asset::Id,
29    /// The exchange rate: how many units of `to` we get for each unit of `from`.
30    pub rate: U128x128,
31    /// A commitment to the balance of this transaction: what assets were consumed and produced.
32    pub balance_commitment: balance::Commitment,
33}
34
35/// The private input for a [`ConvertProof`].
36#[derive(Clone, Debug)]
37pub struct ConvertProofPrivate {
38    /// The private amount of the source asset we're converting.
39    pub amount: Amount,
40    /// The blinding we used to create the public commitment.
41    pub balance_blinding: Fr,
42}
43
44#[cfg(test)]
45fn check_satisfaction(public: &ConvertProofPublic, private: &ConvertProofPrivate) -> Result<()> {
46    let consumed = Value {
47        amount: private.amount,
48        asset_id: public.from,
49    };
50    let produced = Value {
51        amount: public.rate.apply_to_amount(&private.amount)?,
52        asset_id: public.to,
53    };
54    let balance: Balance = Balance::from(produced) - consumed;
55    let commitment = balance.commit(private.balance_blinding);
56    if commitment != public.balance_commitment {
57        anyhow::bail!("balance commitment did not match public input");
58    }
59    Ok(())
60}
61
62#[cfg(test)]
63fn check_circuit_satisfaction(
64    public: ConvertProofPublic,
65    private: ConvertProofPrivate,
66) -> Result<()> {
67    use ark_relations::r1cs::{ConstraintSynthesizer, ConstraintSystem};
68
69    let cs = ConstraintSystem::new_ref();
70    let circuit = ConvertCircuit::new(public, private);
71    cs.set_optimization_goal(r1cs::OptimizationGoal::Constraints);
72    // For why this is ok, see `generate_test_parameters`.
73    circuit
74        .generate_constraints(cs.clone())
75        .expect("can generate constraints from circuit");
76    cs.finalize();
77    if !cs.is_satisfied()? {
78        anyhow::bail!("constraints are not satisfied");
79    }
80    Ok(())
81}
82
83/// A circuit that converts a private amount of one asset into another, by some rate.
84#[derive(Clone, Debug)]
85pub struct ConvertCircuit {
86    /// The amount of the source token being converted.
87    amount: Amount,
88    /// A randomizer for the commitment.
89    balance_blinding: Fr,
90    /// The source asset.
91    from: asset::Id,
92    /// The target asset
93    to: asset::Id,
94    /// The conversion rate from source to target.
95    rate: U128x128,
96    /// A commitment to a balance of `-amount[from] + (rate * amount)[to]`.
97    balance_commitment: balance::Commitment,
98}
99
100impl ConvertCircuit {
101    fn new(
102        ConvertProofPublic {
103            from,
104            to,
105            rate,
106            balance_commitment,
107        }: ConvertProofPublic,
108        ConvertProofPrivate {
109            amount,
110            balance_blinding,
111        }: ConvertProofPrivate,
112    ) -> Self {
113        Self {
114            amount,
115            balance_blinding,
116            balance_commitment,
117            from,
118            to,
119            rate,
120        }
121    }
122}
123
124impl r1cs::ConstraintSynthesizer<Fq> for ConvertCircuit {
125    fn generate_constraints(self, cs: r1cs::ConstraintSystemRef<Fq>) -> r1cs::Result<()> {
126        use ark_r1cs_std::prelude::*;
127
128        // Witnesses
129        let amount_var = AmountVar::new_witness(cs.clone(), || Ok(self.amount))?;
130        let balance_blinding_var = {
131            let balance_blinding_arr: [u8; 32] = self.balance_blinding.to_bytes();
132            UInt8::new_witness_vec(cs.clone(), &balance_blinding_arr)?
133        };
134
135        // Public Inputs
136        let from = AssetIdVar::new_input(cs.clone(), || Ok(self.from))?;
137        let to = AssetIdVar::new_input(cs.clone(), || Ok(self.to))?;
138        let rate = U128x128Var::new_input(cs.clone(), || Ok(self.rate))?;
139        let balance_commitment =
140            BalanceCommitmentVar::new_input(cs.clone(), || Ok(self.balance_commitment))?;
141
142        // Constraints
143        let expected_balance = {
144            let taken = BalanceVar::from_negative_value_var(ValueVar {
145                amount: amount_var.clone(),
146                asset_id: from,
147            });
148
149            let produced = BalanceVar::from_positive_value_var(ValueVar {
150                amount: rate.apply_to_amount(amount_var)?,
151                asset_id: to,
152            });
153
154            taken + produced
155        };
156        let expected_commitment = expected_balance.commit(balance_blinding_var)?;
157        expected_commitment.enforce_equal(&balance_commitment)?;
158
159        Ok(())
160    }
161}
162
163impl DummyWitness for ConvertCircuit {
164    fn with_dummy_witness() -> Self {
165        let amount = Amount::from(1u64);
166        let balance_blinding = Fr::from(1u64);
167        let from = *STAKING_TOKEN_ASSET_ID;
168        let to = *STAKING_TOKEN_ASSET_ID;
169        let rate = U128x128::from(1u64);
170        let balance = Balance::from(Value {
171            asset_id: to,
172            amount,
173        }) - Balance::from(Value {
174            asset_id: from,
175            amount,
176        });
177        let balance_commitment = balance.commit(balance_blinding);
178        Self {
179            amount,
180            balance_blinding,
181            from,
182            to,
183            rate,
184            balance_commitment,
185        }
186    }
187}
188
189/// A proof that one asset was correctly converted into another.
190///
191/// This checks that: `COMMITMENT = COMMIT(-amount[FROM] + (RATE * amount)[TO])`,
192/// where `amount` is private, and other variables are public.
193#[derive(Clone, Debug)]
194pub struct ConvertProof([u8; GROTH16_PROOF_LENGTH_BYTES]);
195
196impl ConvertProof {
197    /// Generate a [`ConvertProof`]
198    pub fn prove(
199        blinding_r: Fq,
200        blinding_s: Fq,
201        pk: &ProvingKey<Bls12_377>,
202        public: ConvertProofPublic,
203        private: ConvertProofPrivate,
204    ) -> Result<Self> {
205        let circuit = ConvertCircuit::new(public, private);
206        let proof = Groth16::<Bls12_377, LibsnarkReduction>::create_proof_with_reduction(
207            circuit, pk, blinding_r, blinding_s,
208        )?;
209        let mut proof_bytes = [0u8; GROTH16_PROOF_LENGTH_BYTES];
210        Proof::serialize_compressed(&proof, &mut proof_bytes[..]).expect("can serialize Proof");
211        Ok(Self(proof_bytes))
212    }
213
214    #[tracing::instrument(level="debug", skip(self, vk), fields(self = ?BASE64_STANDARD.encode(&self.0), vk = ?vk.debug_id()))]
215    pub fn verify(
216        &self,
217        vk: &PreparedVerifyingKey<Bls12_377>,
218        public: ConvertProofPublic,
219    ) -> Result<()> {
220        let proof = Proof::deserialize_compressed_unchecked(&self.0[..]).map_err(|e| anyhow!(e))?;
221
222        let mut public_inputs = Vec::new();
223        public_inputs.extend(
224            public
225                .from
226                .to_field_elements()
227                .ok_or_else(|| anyhow!("could not convert `from` asset ID to field elements"))?,
228        );
229        public_inputs.extend(
230            public
231                .to
232                .to_field_elements()
233                .ok_or_else(|| anyhow!("could not convert `to` asset ID to field elements"))?,
234        );
235        public_inputs.extend(
236            public
237                .rate
238                .to_field_elements()
239                .ok_or_else(|| anyhow!("could not convert exchange rate to field elements"))?,
240        );
241        public_inputs.extend(
242            public
243                .balance_commitment
244                .0
245                .to_field_elements()
246                .ok_or_else(|| anyhow!("could not convert balance commitment to field elements"))?,
247        );
248
249        tracing::trace!(?public_inputs);
250        let start = std::time::Instant::now();
251        let proof_result = Groth16::<Bls12_377, LibsnarkReduction>::verify_with_processed_vk(
252            vk,
253            public_inputs.as_slice(),
254            &proof,
255        )?;
256        tracing::debug!(?proof_result, elapsed = ?start.elapsed());
257        proof_result
258            .then_some(())
259            .ok_or_else(|| anyhow!("undelegate claim proof did not verify"))
260    }
261
262    pub fn to_vec(&self) -> Vec<u8> {
263        self.0.to_vec()
264    }
265}
266
267impl TryFrom<&[u8]> for ConvertProof {
268    type Error = anyhow::Error;
269
270    fn try_from(value: &[u8]) -> Result<Self> {
271        Ok(Self(value.try_into()?))
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use proptest::prelude::*;
279
280    prop_compose! {
281        fn arb_valid_convert_statement(balance_blinding: Fr)(amount in any::<u64>(), from_asset_id64 in any::<u64>(), to_asset_id64 in any::<u64>(), rate in any::<(u64, u128)>()) -> (ConvertProofPublic, ConvertProofPrivate) {
282            let rate = U128x128::ratio(u128::from(rate.0), rate.1).expect("the bounds will make this not overflow");
283            let from = asset::Id(Fq::from(from_asset_id64));
284            let to = asset::Id(Fq::from(to_asset_id64));
285            let amount = Amount::from(amount);
286            let balance = Balance::from(Value { asset_id: to, amount: rate.apply_to_amount(&amount).expect("the bounds will make this not overflow")}) - Value {asset_id: from, amount};
287            let public = ConvertProofPublic { from, to, rate, balance_commitment: balance.commit(balance_blinding) };
288            let private = ConvertProofPrivate { amount, balance_blinding };
289            (public, private)
290        }
291    }
292
293    proptest! {
294        #[test]
295        fn convert_proof_happy_path((public, private) in arb_valid_convert_statement(Fr::from(1u64))) {
296            assert!(check_satisfaction(&public, &private).is_ok());
297            assert!(check_circuit_satisfaction(public, private).is_ok());
298        }
299    }
300
301    fn nonzero_u128() -> impl Strategy<Value = u128> {
302        prop::num::u128::ANY.prop_filter("nonzero", |x| *x != 0)
303    }
304
305    fn nonzero_u64() -> impl Strategy<Value = u64> {
306        prop::num::u64::ANY.prop_filter("nonzero", |x| *x != 0)
307    }
308
309    prop_compose! {
310        // The circuit should be unsatisfiable if the rate used by the prover is incorrect.
311        // We generate a random rate, filtering out non-zero denominators to avoid division by zero.
312        // This is the "true" rate.
313        // Next, we add a (u64) random value to the true rate, and the prover generates the balance
314        // using this incorrect rate.
315        fn arb_invalid_convert_statement_wrong_rate(balance_blinding: Fr)(amount in any::<u64>(), from_asset_id64 in any::<u64>(), to_asset_id64 in any::<u64>(), rate_num in nonzero_u64(), rate_denom in nonzero_u128(), random_rate_num in nonzero_u64()) -> (ConvertProofPublic, ConvertProofPrivate) {
316            let rate = U128x128::ratio(u128::from(rate_num), rate_denom).expect("the bounds will make this not overflow");
317            let incorrect_rate = rate.checked_add(&U128x128::ratio(random_rate_num, 1u64).expect("should not overflow")).expect("should not overflow");
318            let from = asset::Id(Fq::from(from_asset_id64));
319            let to = asset::Id(Fq::from(to_asset_id64));
320            let amount = Amount::from(amount);
321            let balance = Balance::from(Value { asset_id: to, amount: incorrect_rate.apply_to_amount(&amount).expect("the bounds will make this not overflow")}) - Value {asset_id: from, amount};
322            let public = ConvertProofPublic { from, to, rate, balance_commitment: balance.commit(balance_blinding) };
323            let private = ConvertProofPrivate { amount, balance_blinding };
324            (public, private)
325        }
326    }
327
328    proptest! {
329        #[test]
330        fn convert_proof_invalid_convert_statement_wrong_rate((public, private) in arb_invalid_convert_statement_wrong_rate(Fr::from(1u64))) {
331            assert!(check_satisfaction(&public, &private).is_err());
332            assert!(check_circuit_satisfaction(public, private).is_err());
333        }
334    }
335}