spl_token_confidential_transfer_proof_generation/
transfer_with_fee.rs

1//! Generates the zero-knowledge proofs required for a confidential transfer with a fee.
2//!
3//! A confidential transfer with a fee is more complex than a simple transfer. It requires five
4//! distinct zero-knowledge proofs to ensure the validity of the transfer, the solvency of the
5//! sender, and the correctness of the fee amount according to the on-chain mint configuration.
6//!
7//! ## Protocol Flow and Proof Components
8//!
9//! 1.  **Fee Calculation**: The client first calculates the required fee based on the transfer
10//!     amount and the on-chain fee parameters (rate and maximum cap).
11//!
12//! 2.  **Encrypt Amounts**: The gross transfer amount and the fee amount are each split into low
13//!     and high bit components. These components are then encrypted into separate grouped (twisted)
14//!     ElGamal ciphertexts with the appropriate decryption handles for the involved parties (source,
15//!     destination, auditor, and withdraw authority).
16//!
17//! 3.  **Generate Proofs**: The sender generates five proofs that work in concert:
18//!
19//!     -   **Transfer Amount Ciphertext Validity Proof
20//!         (`BatchedGroupedCiphertext3HandlesValidityProofData`)**: Certifies that the grouped
21//!         ElGamal ciphertext for the gross transfer amount is well-formed.
22//!
23//!     -   **Fee Ciphertext Validity Proof
24//!         (`BatchedGroupedCiphertext2HandlesValidityProofData`)**: Certifies that the grouped
25//!         ElGamal ciphertext for the transfer fee is well-formed.
26//!
27//!     -   **Fee Calculation Proof (`PercentageWithCapProofData`)**:
28//!         It's a "one-of-two" proof that certifies **either**:
29//!           1. The `fee_amount` is exactly equal to the on-chain `maximum_fee`.
30//!           2. The `fee_amount` was correctly calculated as a percentage of the
31//!              `transfer_amount`, according to the on-chain `fee_rate_basis_points`.
32//!
33//!         **Note**: The proof certifies that the transfer fee is a valid percentage of the
34//!         transfer amount or that the fee is exactly the maximum fee. While the sender is
35//!         expected to choose the lower of these two options, the proof does not enforce this
36//!         choice.
37//!
38//!     -   **Range Proof (`BatchedRangeProofU256Data`)**:
39//!         This expanded range proof ensures the solvency of the entire transaction by certifying
40//!         that all critical monetary values are non-negative. This includes the sender's remaining
41//!         balance, the gross transfer amount, the fee amount, and the net transfer amount that the
42//!         destination receives.
43//!
44//!     -   **Ciphertext-Commitment Equality Proof (`CiphertextCommitmentEqualityProofData`)**:
45//!         Identical in purpose to the simple transfer, this proof links the sender's remaining
46//!         balance (as a homomorphically computed ElGamal ciphertext) to a new Pedersen commitment.
47//!         This commitment is then used in the Range Proof to prove the sender's solvency.
48
49#[cfg(not(target_arch = "wasm32"))]
50use solana_zk_sdk::encryption::grouped_elgamal::GroupedElGamal;
51#[cfg(target_arch = "wasm32")]
52use solana_zk_sdk::encryption::grouped_elgamal::{
53    GroupedElGamalCiphertext2Handles, GroupedElGamalCiphertext3Handles,
54};
55use {
56    crate::{
57        encryption::{FeeCiphertext, TransferAmountCiphertext},
58        errors::TokenProofGenerationError,
59        try_combine_lo_hi_ciphertexts, try_combine_lo_hi_commitments, try_combine_lo_hi_openings,
60        try_split_u64, CiphertextValidityProofWithAuditorCiphertext, TRANSFER_AMOUNT_HI_BITS,
61        TRANSFER_AMOUNT_LO_BITS,
62    },
63    curve25519_dalek::scalar::Scalar,
64    solana_zk_sdk::{
65        encryption::{
66            auth_encryption::{AeCiphertext, AeKey},
67            elgamal::{ElGamalCiphertext, ElGamalKeypair, ElGamalPubkey},
68            pedersen::{Pedersen, PedersenCommitment, PedersenOpening},
69        },
70        zk_elgamal_proof_program::proof_data::{
71            BatchedGroupedCiphertext2HandlesValidityProofData,
72            BatchedGroupedCiphertext3HandlesValidityProofData, BatchedRangeProofU256Data,
73            CiphertextCommitmentEqualityProofData, PercentageWithCapProofData, ZkProofData,
74        },
75    },
76};
77
78const MAX_FEE_BASIS_POINTS_SUB_ONE: u64 = 9_999;
79const MAX_FEE_BASIS_POINTS: u64 = 10_000;
80const ONE_IN_BASIS_POINTS: u128 = MAX_FEE_BASIS_POINTS as u128;
81
82const FEE_AMOUNT_LO_BITS: usize = 16;
83const FEE_AMOUNT_HI_BITS: usize = 32;
84
85const REMAINING_BALANCE_BIT_LENGTH: usize = 64;
86const DELTA_BIT_LENGTH: usize = 16;
87const NET_TRANSFER_AMOUNT_BIT_LENGTH: usize = 64;
88
89/// The proof data required for a confidential transfer instruction when the
90/// mint is extended for fees
91pub struct TransferWithFeeProofData {
92    pub equality_proof_data: CiphertextCommitmentEqualityProofData,
93    pub transfer_amount_ciphertext_validity_proof_data_with_ciphertext:
94        CiphertextValidityProofWithAuditorCiphertext,
95    pub percentage_with_cap_proof_data: PercentageWithCapProofData,
96    pub fee_ciphertext_validity_proof_data: BatchedGroupedCiphertext2HandlesValidityProofData,
97    pub range_proof_data: BatchedRangeProofU256Data,
98}
99
100#[allow(clippy::too_many_arguments)]
101pub fn transfer_with_fee_split_proof_data(
102    current_available_balance: &ElGamalCiphertext,
103    current_decryptable_available_balance: &AeCiphertext,
104    transfer_amount: u64,
105    source_elgamal_keypair: &ElGamalKeypair,
106    aes_key: &AeKey,
107    destination_elgamal_pubkey: &ElGamalPubkey,
108    auditor_elgamal_pubkey: Option<&ElGamalPubkey>,
109    withdraw_withheld_authority_elgamal_pubkey: &ElGamalPubkey,
110    fee_rate_basis_points: u16,
111    maximum_fee: u64,
112) -> Result<TransferWithFeeProofData, TokenProofGenerationError> {
113    let default_auditor_pubkey = ElGamalPubkey::default();
114    let auditor_elgamal_pubkey = auditor_elgamal_pubkey.unwrap_or(&default_auditor_pubkey);
115
116    // Split the transfer amount into the low and high bit components
117    let (transfer_amount_lo, transfer_amount_hi) =
118        try_split_u64(transfer_amount, TRANSFER_AMOUNT_LO_BITS)
119            .ok_or(TokenProofGenerationError::IllegalAmountBitLength)?;
120
121    // Encrypt the `lo` and `hi` transfer amounts
122    let (transfer_amount_grouped_ciphertext_lo, transfer_amount_opening_lo) =
123        TransferAmountCiphertext::new(
124            transfer_amount_lo,
125            source_elgamal_keypair.pubkey(),
126            destination_elgamal_pubkey,
127            auditor_elgamal_pubkey,
128        );
129    #[cfg(not(target_arch = "wasm32"))]
130    let grouped_ciphertext_lo = transfer_amount_grouped_ciphertext_lo.0;
131    #[cfg(target_arch = "wasm32")]
132    let grouped_ciphertext_lo = GroupedElGamalCiphertext3Handles::encrypt_with_u64(
133        source_elgamal_keypair.pubkey(),
134        destination_elgamal_pubkey,
135        auditor_elgamal_pubkey,
136        transfer_amount_lo,
137        &transfer_amount_opening_lo,
138    );
139
140    let (transfer_amount_grouped_ciphertext_hi, transfer_amount_opening_hi) =
141        TransferAmountCiphertext::new(
142            transfer_amount_hi,
143            source_elgamal_keypair.pubkey(),
144            destination_elgamal_pubkey,
145            auditor_elgamal_pubkey,
146        );
147    #[cfg(not(target_arch = "wasm32"))]
148    let grouped_ciphertext_hi = transfer_amount_grouped_ciphertext_hi.0;
149    #[cfg(target_arch = "wasm32")]
150    let grouped_ciphertext_hi = GroupedElGamalCiphertext3Handles::encrypt_with_u64(
151        source_elgamal_keypair.pubkey(),
152        destination_elgamal_pubkey,
153        auditor_elgamal_pubkey,
154        transfer_amount_hi,
155        &transfer_amount_opening_hi,
156    );
157
158    // Decrypt the current available balance at the source
159    let current_decrypted_available_balance = current_decryptable_available_balance
160        .decrypt(aes_key)
161        .ok_or(TokenProofGenerationError::IllegalAmountBitLength)?;
162
163    // Compute the remaining balance at the source
164    let new_decrypted_available_balance = current_decrypted_available_balance
165        .checked_sub(transfer_amount)
166        .ok_or(TokenProofGenerationError::NotEnoughFunds)?;
167
168    // Create a new Pedersen commitment for the remaining balance at the source
169    let (new_available_balance_commitment, new_source_opening) =
170        Pedersen::new(new_decrypted_available_balance);
171
172    // Compute the remaining balance at the source as ElGamal ciphertexts
173    let transfer_amount_source_ciphertext_lo = transfer_amount_grouped_ciphertext_lo
174        .0
175        .to_elgamal_ciphertext(0)
176        .unwrap();
177
178    let transfer_amount_source_ciphertext_hi = transfer_amount_grouped_ciphertext_hi
179        .0
180        .to_elgamal_ciphertext(0)
181        .unwrap();
182
183    #[allow(clippy::arithmetic_side_effects)]
184    let new_available_balance_ciphertext = current_available_balance
185        - try_combine_lo_hi_ciphertexts(
186            &transfer_amount_source_ciphertext_lo,
187            &transfer_amount_source_ciphertext_hi,
188            TRANSFER_AMOUNT_LO_BITS,
189        )
190        .ok_or(TokenProofGenerationError::IllegalAmountBitLength)?;
191
192    // generate equality proof data
193    let equality_proof_data = CiphertextCommitmentEqualityProofData::new(
194        source_elgamal_keypair,
195        &new_available_balance_ciphertext,
196        &new_available_balance_commitment,
197        &new_source_opening,
198        new_decrypted_available_balance,
199    )
200    .map_err(TokenProofGenerationError::from)?;
201
202    // generate ciphertext validity data
203    let transfer_amount_ciphertext_validity_proof_data =
204        BatchedGroupedCiphertext3HandlesValidityProofData::new(
205            source_elgamal_keypair.pubkey(),
206            destination_elgamal_pubkey,
207            auditor_elgamal_pubkey,
208            &grouped_ciphertext_lo,
209            &grouped_ciphertext_hi,
210            transfer_amount_lo,
211            transfer_amount_hi,
212            &transfer_amount_opening_lo,
213            &transfer_amount_opening_hi,
214        )
215        .map_err(TokenProofGenerationError::from)?;
216
217    let transfer_amount_auditor_ciphertext_lo = transfer_amount_ciphertext_validity_proof_data
218        .context_data()
219        .grouped_ciphertext_lo
220        .try_extract_ciphertext(2)
221        .map_err(|_| TokenProofGenerationError::CiphertextExtraction)?;
222
223    let transfer_amount_auditor_ciphertext_hi = transfer_amount_ciphertext_validity_proof_data
224        .context_data()
225        .grouped_ciphertext_hi
226        .try_extract_ciphertext(2)
227        .map_err(|_| TokenProofGenerationError::CiphertextExtraction)?;
228
229    let transfer_amount_ciphertext_validity_proof_data_with_ciphertext =
230        CiphertextValidityProofWithAuditorCiphertext {
231            proof_data: transfer_amount_ciphertext_validity_proof_data,
232            ciphertext_lo: transfer_amount_auditor_ciphertext_lo,
233            ciphertext_hi: transfer_amount_auditor_ciphertext_hi,
234        };
235
236    // calculate fee
237    let transfer_fee_basis_points = fee_rate_basis_points;
238    let transfer_fee_maximum_fee = maximum_fee;
239    let (raw_fee_amount, raw_delta_fee) = calculate_fee(transfer_amount, transfer_fee_basis_points)
240        .ok_or(TokenProofGenerationError::FeeCalculation)?;
241
242    // if raw fee is greater than the maximum fee, then use the maximum fee for the
243    // fee amount and set the claimed delta fee to be 0 for simplicity
244    let (fee_amount, claimed_delta_fee) = if transfer_fee_maximum_fee < raw_fee_amount {
245        (transfer_fee_maximum_fee, 0)
246    } else {
247        (raw_fee_amount, raw_delta_fee)
248    };
249    let net_transfer_amount = transfer_amount
250        .checked_sub(fee_amount)
251        .ok_or(TokenProofGenerationError::FeeCalculation)?;
252
253    // split and encrypt fee
254    let (fee_amount_lo, fee_amount_hi) = try_split_u64(fee_amount, FEE_AMOUNT_LO_BITS)
255        .ok_or(TokenProofGenerationError::IllegalAmountBitLength)?;
256    let (fee_ciphertext_lo, fee_opening_lo) = FeeCiphertext::new(
257        fee_amount_lo,
258        destination_elgamal_pubkey,
259        withdraw_withheld_authority_elgamal_pubkey,
260    );
261    let (fee_ciphertext_hi, fee_opening_hi) = FeeCiphertext::new(
262        fee_amount_hi,
263        destination_elgamal_pubkey,
264        withdraw_withheld_authority_elgamal_pubkey,
265    );
266
267    // create combined commitments and openings to be used to generate proofs
268    let combined_transfer_amount_commitment = try_combine_lo_hi_commitments(
269        transfer_amount_grouped_ciphertext_lo.get_commitment(),
270        transfer_amount_grouped_ciphertext_hi.get_commitment(),
271        TRANSFER_AMOUNT_LO_BITS,
272    )
273    .ok_or(TokenProofGenerationError::IllegalAmountBitLength)?;
274    let combined_transfer_amount_opening = try_combine_lo_hi_openings(
275        &transfer_amount_opening_lo,
276        &transfer_amount_opening_hi,
277        TRANSFER_AMOUNT_LO_BITS,
278    )
279    .ok_or(TokenProofGenerationError::IllegalAmountBitLength)?;
280
281    let combined_fee_commitment = try_combine_lo_hi_commitments(
282        fee_ciphertext_lo.get_commitment(),
283        fee_ciphertext_hi.get_commitment(),
284        FEE_AMOUNT_LO_BITS,
285    )
286    .ok_or(TokenProofGenerationError::IllegalAmountBitLength)?;
287    let combined_fee_opening =
288        try_combine_lo_hi_openings(&fee_opening_lo, &fee_opening_hi, FEE_AMOUNT_LO_BITS)
289            .ok_or(TokenProofGenerationError::IllegalAmountBitLength)?;
290
291    // compute net transfer amount = transfer_amount - fee
292    #[allow(clippy::arithmetic_side_effects)]
293    let net_transfer_amount_commitment =
294        combined_transfer_amount_commitment - combined_fee_commitment;
295    #[allow(clippy::arithmetic_side_effects)]
296    let net_transfer_amount_opening = &combined_transfer_amount_opening - &combined_fee_opening;
297
298    // compute claimed and real delta commitment
299    let (claimed_commitment, claimed_opening) = Pedersen::new(claimed_delta_fee);
300    let (delta_commitment, delta_opening) = compute_delta_commitment_and_opening(
301        (
302            &combined_transfer_amount_commitment,
303            &combined_transfer_amount_opening,
304        ),
305        (&combined_fee_commitment, &combined_fee_opening),
306        transfer_fee_basis_points,
307    );
308
309    // generate fee sigma proof
310    let percentage_with_cap_proof_data = PercentageWithCapProofData::new(
311        &combined_fee_commitment,
312        &combined_fee_opening,
313        fee_amount,
314        &delta_commitment,
315        &delta_opening,
316        claimed_delta_fee,
317        &claimed_commitment,
318        &claimed_opening,
319        transfer_fee_maximum_fee,
320    )
321    .map_err(TokenProofGenerationError::from)?;
322
323    // encrypt the fee amount under the destination and withdraw withheld authority
324    // ElGamal public key
325    #[cfg(not(target_arch = "wasm32"))]
326    let fee_destination_withdraw_withheld_authority_ciphertext_lo = GroupedElGamal::encrypt_with(
327        [
328            destination_elgamal_pubkey,
329            withdraw_withheld_authority_elgamal_pubkey,
330        ],
331        fee_amount_lo,
332        &fee_opening_lo,
333    );
334    #[cfg(target_arch = "wasm32")]
335    let fee_destination_withdraw_withheld_authority_ciphertext_lo =
336        GroupedElGamalCiphertext2Handles::encrypt_with_u64(
337            destination_elgamal_pubkey,
338            withdraw_withheld_authority_elgamal_pubkey,
339            fee_amount_lo,
340            &fee_opening_lo,
341        );
342
343    #[cfg(not(target_arch = "wasm32"))]
344    let fee_destination_withdraw_withheld_authority_ciphertext_hi = GroupedElGamal::encrypt_with(
345        [
346            destination_elgamal_pubkey,
347            withdraw_withheld_authority_elgamal_pubkey,
348        ],
349        fee_amount_hi,
350        &fee_opening_hi,
351    );
352    #[cfg(target_arch = "wasm32")]
353    let fee_destination_withdraw_withheld_authority_ciphertext_hi =
354        GroupedElGamalCiphertext2Handles::encrypt_with_u64(
355            destination_elgamal_pubkey,
356            withdraw_withheld_authority_elgamal_pubkey,
357            fee_amount_hi,
358            &fee_opening_hi,
359        );
360
361    // generate fee ciphertext validity data
362    let fee_ciphertext_validity_proof_data =
363        BatchedGroupedCiphertext2HandlesValidityProofData::new(
364            destination_elgamal_pubkey,
365            withdraw_withheld_authority_elgamal_pubkey,
366            &fee_destination_withdraw_withheld_authority_ciphertext_lo,
367            &fee_destination_withdraw_withheld_authority_ciphertext_hi,
368            fee_amount_lo,
369            fee_amount_hi,
370            &fee_opening_lo,
371            &fee_opening_hi,
372        )
373        .map_err(TokenProofGenerationError::from)?;
374
375    // generate range proof data
376    let delta_fee_complement = MAX_FEE_BASIS_POINTS_SUB_ONE
377        .checked_sub(claimed_delta_fee)
378        .ok_or(TokenProofGenerationError::FeeCalculation)?;
379
380    let max_fee_basis_points_sub_one_commitment =
381        Pedersen::with(MAX_FEE_BASIS_POINTS_SUB_ONE, &PedersenOpening::default());
382    #[allow(clippy::arithmetic_side_effects)]
383    let claimed_complement_commitment =
384        max_fee_basis_points_sub_one_commitment - claimed_commitment;
385    #[allow(clippy::arithmetic_side_effects)]
386    let claimed_complement_opening = PedersenOpening::default() - &claimed_opening;
387
388    let range_proof_data = BatchedRangeProofU256Data::new(
389        vec![
390            &new_available_balance_commitment,
391            transfer_amount_grouped_ciphertext_lo.get_commitment(),
392            transfer_amount_grouped_ciphertext_hi.get_commitment(),
393            &claimed_commitment,
394            &claimed_complement_commitment,
395            fee_ciphertext_lo.get_commitment(),
396            fee_ciphertext_hi.get_commitment(),
397            &net_transfer_amount_commitment,
398        ],
399        vec![
400            new_decrypted_available_balance,
401            transfer_amount_lo,
402            transfer_amount_hi,
403            claimed_delta_fee,
404            delta_fee_complement,
405            fee_amount_lo,
406            fee_amount_hi,
407            net_transfer_amount,
408        ],
409        vec![
410            REMAINING_BALANCE_BIT_LENGTH,
411            TRANSFER_AMOUNT_LO_BITS,
412            TRANSFER_AMOUNT_HI_BITS,
413            DELTA_BIT_LENGTH,
414            DELTA_BIT_LENGTH,
415            FEE_AMOUNT_LO_BITS,
416            FEE_AMOUNT_HI_BITS,
417            NET_TRANSFER_AMOUNT_BIT_LENGTH,
418        ],
419        vec![
420            &new_source_opening,
421            &transfer_amount_opening_lo,
422            &transfer_amount_opening_hi,
423            &claimed_opening,
424            &claimed_complement_opening,
425            &fee_opening_lo,
426            &fee_opening_hi,
427            &net_transfer_amount_opening,
428        ],
429    )
430    .map_err(TokenProofGenerationError::from)?;
431
432    Ok(TransferWithFeeProofData {
433        equality_proof_data,
434        transfer_amount_ciphertext_validity_proof_data_with_ciphertext,
435        percentage_with_cap_proof_data,
436        fee_ciphertext_validity_proof_data,
437        range_proof_data,
438    })
439}
440
441fn calculate_fee(transfer_amount: u64, fee_rate_basis_points: u16) -> Option<(u64, u64)> {
442    let numerator = (transfer_amount as u128).checked_mul(fee_rate_basis_points as u128)?;
443
444    // Warning: Division may involve CPU opcodes that have variable execution times.
445    // This non-constant-time execution of the fee calculation can theoretically
446    // reveal information about the transfer amount. For transfers that involve
447    // extremely sensitive data, additional care should be put into how the fees
448    // are calculated.
449    let fee = numerator
450        .checked_add(ONE_IN_BASIS_POINTS)?
451        .checked_sub(1)?
452        .checked_div(ONE_IN_BASIS_POINTS)?;
453
454    let delta_fee = fee
455        .checked_mul(ONE_IN_BASIS_POINTS)?
456        .checked_sub(numerator)?;
457
458    Some((fee as u64, delta_fee as u64))
459}
460
461#[allow(clippy::arithmetic_side_effects)]
462fn compute_delta_commitment_and_opening(
463    (combined_commitment, combined_opening): (&PedersenCommitment, &PedersenOpening),
464    (combined_fee_commitment, combined_fee_opening): (&PedersenCommitment, &PedersenOpening),
465    fee_rate_basis_points: u16,
466) -> (PedersenCommitment, PedersenOpening) {
467    let fee_rate_scalar = Scalar::from(fee_rate_basis_points);
468    let delta_commitment = combined_fee_commitment * Scalar::from(MAX_FEE_BASIS_POINTS)
469        - combined_commitment * fee_rate_scalar;
470    let delta_opening = combined_fee_opening * Scalar::from(MAX_FEE_BASIS_POINTS)
471        - combined_opening * fee_rate_scalar;
472
473    (delta_commitment, delta_opening)
474}