Skip to main content

dpp/shielded/builder/
token_pool_paid.rs

1use grovedb_commitment_tree::{
2    Anchor, Builder, BundleType, DashMemo, Flags, FullViewingKey, NoteValue, PaymentAddress, Scope,
3    SpendAuthorizingKey,
4};
5
6use crate::address_funds::OrchardAddress;
7use crate::balances::credits::TokenAmount;
8use crate::data_contract::TokenContractPosition;
9use crate::fee::Credits;
10use crate::shielded::{
11    compute_token_purchase_from_shielded_pool_fee,
12    compute_token_shielded_transfer_with_shielded_fee_fee,
13    compute_token_unshield_with_shielded_fee_fee, token_pool_fee_bundle_extra_sighash_data,
14    token_purchase_from_shielded_pool_extra_sighash_data,
15    token_shielded_transfer_with_shielded_fee_extra_sighash_data,
16    token_unshield_with_shielded_fee_extra_sighash_data, SerializedAction,
17    TOKEN_PURCHASE_FROM_SHIELDED_POOL_TYPE, TOKEN_SHIELDED_TRANSFER_WITH_SHIELDED_FEE_TYPE,
18    TOKEN_UNSHIELD_WITH_SHIELDED_FEE_TYPE,
19};
20use crate::state_transition::token_purchase_from_shielded_pool_transition::methods::TokenPurchaseFromShieldedPoolTransitionMethodsV0;
21use crate::state_transition::token_purchase_from_shielded_pool_transition::TokenPurchaseFromShieldedPoolTransition;
22use crate::state_transition::token_shielded_transfer_with_shielded_fee_transition::methods::TokenShieldedTransferWithShieldedFeeTransitionMethodsV0;
23use crate::state_transition::token_shielded_transfer_with_shielded_fee_transition::TokenShieldedTransferWithShieldedFeeTransition;
24use crate::state_transition::token_unshield_with_shielded_fee_transition::methods::TokenUnshieldWithShieldedFeeTransitionMethodsV0;
25use crate::state_transition::token_unshield_with_shielded_fee_transition::TokenUnshieldWithShieldedFeeTransition;
26use crate::state_transition::StateTransition;
27use crate::ProtocolError;
28use platform_value::Identifier;
29use platform_version::version::PlatformVersion;
30
31use super::{
32    build_spend_bundle, prove_and_sign_bundle, serialize_authorized_bundle, OrchardProver,
33    SerializedBundle, SpendableNote,
34};
35
36/// The credit pool side of an identity-less token pool transition: the wallet's credit pool
37/// notes to spend, where the change goes, and the keys that authorize the spend.
38pub struct ShieldedFeePayer<'a> {
39    /// Credit pool notes to spend, with their Merkle paths.
40    pub spends: Vec<SpendableNote>,
41    /// Orchard address for the change note in the credit pool.
42    pub change_address: &'a OrchardAddress,
43    /// Full viewing key of the credit pool notes.
44    pub fvk: &'a FullViewingKey,
45    /// Spend authorizing key of the credit pool notes.
46    pub ask: &'a SpendAuthorizingKey,
47    /// Sinsemilla root of the credit pool's note commitment tree.
48    pub anchor: Anchor,
49}
50
51/// The wallet's notes in a token's shielded pool and the keys that spend them.
52pub struct TokenPoolSpender<'a> {
53    /// Token pool notes to spend, with their Merkle paths.
54    pub spends: Vec<SpendableNote>,
55    /// Orchard address for the change note in the token pool.
56    pub change_address: &'a OrchardAddress,
57    /// Full viewing key of the token pool notes.
58    pub fvk: &'a FullViewingKey,
59    /// Spend authorizing key of the token pool notes.
60    pub ask: &'a SpendAuthorizingKey,
61    /// Sinsemilla root of the token pool's note commitment tree.
62    pub anchor: Anchor,
63}
64
65fn total_value(spends: &[SpendableNote]) -> Result<u64, ProtocolError> {
66    spends
67        .iter()
68        .try_fold(0u64, |total, spend| {
69            total.checked_add(spend.note.value().inner())
70        })
71        .ok_or_else(|| {
72            ProtocolError::ShieldedBuildError("total spendable value overflows u64".to_string())
73        })
74}
75
76/// Builds the credit pool fee bundle: spends `payer.spends`, carves `credits_leaving` (the fee,
77/// plus the price for a purchase) and returns the remainder as change, bound to the token
78/// bundle through `token_actions`.
79#[allow(clippy::too_many_arguments)]
80fn build_fee_bundle<P: OrchardProver>(
81    state_transition_type: u8,
82    token_id: &Identifier,
83    token_actions: &[SerializedAction],
84    payer: ShieldedFeePayer<'_>,
85    credits_leaving: Credits,
86    expected_actions: usize,
87    prover: &P,
88    platform_version: &PlatformVersion,
89) -> Result<SerializedBundle, ProtocolError> {
90    let total_spent = total_value(&payer.spends)?;
91    if credits_leaving > total_spent {
92        return Err(ProtocolError::ShieldedBuildError(format!(
93            "credits leaving the pool {} exceed the total spendable value {}",
94            credits_leaving, total_spent
95        )));
96    }
97    let change_amount = total_spent - credits_leaving;
98    let extra_sighash_data = token_pool_fee_bundle_extra_sighash_data(
99        state_transition_type,
100        &token_id.to_buffer(),
101        token_actions,
102        platform_version,
103    )?;
104    let bundle = build_spend_bundle(
105        payer.spends,
106        payer.change_address,
107        change_amount,
108        [0u8; 36],
109        payer.fvk,
110        payer.ask,
111        payer.anchor,
112        prover,
113        &extra_sighash_data,
114    )?;
115    let sb = serialize_authorized_bundle(&bundle);
116    if sb.value_balance != credits_leaving as i64 {
117        return Err(ProtocolError::ShieldedBuildError(format!(
118            "fee bundle value balance {} does not equal the credits leaving the pool {}",
119            sb.value_balance, credits_leaving
120        )));
121    }
122    if sb.actions.len() != expected_actions {
123        return Err(ProtocolError::ShieldedBuildError(format!(
124            "fee bundle has {} actions, the fee was computed for {}",
125            sb.actions.len(),
126            expected_actions
127        )));
128    }
129    Ok(sb)
130}
131
132/// Builds a `TokenShieldedTransferWithShieldedFee`: pays `transfer_amount` of the token to
133/// `recipient` inside the token's shielded pool (value balance zero) and the fee out of the
134/// credit shielded pool. No identity is involved. Returns the transition and the fee paid.
135#[allow(clippy::too_many_arguments)]
136pub fn build_token_shielded_transfer_with_shielded_fee_transition<P: OrchardProver>(
137    token_id: Identifier,
138    data_contract_id: Identifier,
139    token_contract_position: TokenContractPosition,
140    spender: TokenPoolSpender<'_>,
141    recipient: &OrchardAddress,
142    transfer_amount: TokenAmount,
143    memo: [u8; 36],
144    fee_payer: ShieldedFeePayer<'_>,
145    prover: &P,
146    platform_version: &PlatformVersion,
147) -> Result<(StateTransition, Credits), ProtocolError> {
148    if transfer_amount == 0 {
149        return Err(ProtocolError::ShieldedBuildError(
150            "token shielded transfer amount must be greater than zero".to_string(),
151        ));
152    }
153    let total_spent = total_value(&spender.spends)?;
154    if transfer_amount > total_spent {
155        return Err(ProtocolError::ShieldedBuildError(format!(
156            "token shielded transfer amount {} exceeds total spendable value {}",
157            transfer_amount, total_spent
158        )));
159    }
160    let change_amount = total_spent - transfer_amount;
161    let mut builder = Builder::<DashMemo>::new(BundleType::DEFAULT, spender.anchor);
162    for spend in spender.spends {
163        builder
164            .add_spend(spender.fvk.clone(), spend.note, spend.merkle_path)
165            .map_err(|e| {
166                ProtocolError::ShieldedBuildError(format!("failed to add spend: {:?}", e))
167            })?;
168    }
169    let sender_ovk = spender.fvk.to_ovk(Scope::External);
170    builder
171        .add_output(
172            Some(sender_ovk.clone()),
173            PaymentAddress::from(recipient),
174            NoteValue::from_raw(transfer_amount),
175            memo,
176        )
177        .map_err(|e| ProtocolError::ShieldedBuildError(format!("failed to add output: {:?}", e)))?;
178    if change_amount > 0 {
179        builder
180            .add_output(
181                Some(sender_ovk),
182                PaymentAddress::from(spender.change_address),
183                NoteValue::from_raw(change_amount),
184                [0u8; 36],
185            )
186            .map_err(|e| {
187                ProtocolError::ShieldedBuildError(format!("failed to add change output: {:?}", e))
188            })?;
189    }
190    let token_extra = token_shielded_transfer_with_shielded_fee_extra_sighash_data(
191        &token_id.to_buffer(),
192        platform_version,
193    )?;
194    let token_bundle = prove_and_sign_bundle(
195        builder,
196        prover,
197        std::slice::from_ref(spender.ask),
198        &token_extra,
199    )?;
200    let token_sb = serialize_authorized_bundle(&token_bundle);
201    if token_sb.value_balance != 0 {
202        return Err(ProtocolError::ShieldedBuildError(format!(
203            "token shielded transfer bundle value balance must be zero, got {}",
204            token_sb.value_balance
205        )));
206    }
207    let fee_actions = fee_payer.spends.len().max(2);
208    let fee = compute_token_shielded_transfer_with_shielded_fee_fee(
209        token_sb.actions.len(),
210        fee_actions,
211        platform_version,
212    )?;
213    let fee_sb = build_fee_bundle(
214        TOKEN_SHIELDED_TRANSFER_WITH_SHIELDED_FEE_TYPE,
215        &token_id,
216        &token_sb.actions,
217        fee_payer,
218        fee,
219        fee_actions,
220        prover,
221        platform_version,
222    )?;
223    let transition = TokenShieldedTransferWithShieldedFeeTransition::try_from_bundles(
224        data_contract_id,
225        token_contract_position,
226        token_id,
227        token_sb.actions,
228        token_sb.anchor,
229        token_sb.proof,
230        token_sb.binding_signature,
231        fee_sb.actions,
232        fee_sb.anchor,
233        fee_sb.proof,
234        fee_sb.binding_signature,
235        fee,
236        platform_version,
237    )?;
238    Ok((transition, fee))
239}
240
241/// Builds a `TokenUnshieldWithShieldedFee`: `amount` of the token leaves the token's shielded
242/// pool into `recipient_id`'s balance, the fee is paid out of the credit shielded pool. Returns
243/// the transition and the fee paid.
244#[allow(clippy::too_many_arguments)]
245pub fn build_token_unshield_with_shielded_fee_transition<P: OrchardProver>(
246    token_id: Identifier,
247    data_contract_id: Identifier,
248    token_contract_position: TokenContractPosition,
249    spender: TokenPoolSpender<'_>,
250    recipient_id: Identifier,
251    amount: TokenAmount,
252    memo: [u8; 36],
253    fee_payer: ShieldedFeePayer<'_>,
254    prover: &P,
255    platform_version: &PlatformVersion,
256) -> Result<(StateTransition, Credits), ProtocolError> {
257    if amount == 0 {
258        return Err(ProtocolError::ShieldedBuildError(
259            "token unshield amount must be greater than zero".to_string(),
260        ));
261    }
262    let total_spent = total_value(&spender.spends)?;
263    if amount > total_spent {
264        return Err(ProtocolError::ShieldedBuildError(format!(
265            "token unshield amount {} exceeds total spendable value {}",
266            amount, total_spent
267        )));
268    }
269    let change_amount = total_spent - amount;
270    let token_extra = token_unshield_with_shielded_fee_extra_sighash_data(
271        &token_id.to_buffer(),
272        &recipient_id.to_buffer(),
273        amount,
274        platform_version,
275    )?;
276    let token_bundle = build_spend_bundle(
277        spender.spends,
278        spender.change_address,
279        change_amount,
280        memo,
281        spender.fvk,
282        spender.ask,
283        spender.anchor,
284        prover,
285        &token_extra,
286    )?;
287    let token_sb = serialize_authorized_bundle(&token_bundle);
288    if token_sb.value_balance != amount as i64 {
289        return Err(ProtocolError::ShieldedBuildError(format!(
290            "token unshield bundle value balance {} does not equal the amount {}",
291            token_sb.value_balance, amount
292        )));
293    }
294    let fee_actions = fee_payer.spends.len().max(2);
295    let fee = compute_token_unshield_with_shielded_fee_fee(
296        token_sb.actions.len(),
297        fee_actions,
298        platform_version,
299    )?;
300    let fee_sb = build_fee_bundle(
301        TOKEN_UNSHIELD_WITH_SHIELDED_FEE_TYPE,
302        &token_id,
303        &token_sb.actions,
304        fee_payer,
305        fee,
306        fee_actions,
307        prover,
308        platform_version,
309    )?;
310    let transition = TokenUnshieldWithShieldedFeeTransition::try_from_bundles(
311        data_contract_id,
312        token_contract_position,
313        token_id,
314        recipient_id,
315        amount,
316        token_sb.actions,
317        token_sb.anchor,
318        token_sb.proof,
319        token_sb.binding_signature,
320        fee_sb.actions,
321        fee_sb.anchor,
322        fee_sb.proof,
323        fee_sb.binding_signature,
324        fee,
325        platform_version,
326    )?;
327    Ok((transition, fee))
328}
329
330/// Builds a `TokenPurchaseFromShieldedPool`: `token_count` tokens are bought at
331/// `total_agreed_price` credits (which must match the token's direct purchase price) paid out
332/// of the credit shielded pool together with the fee, and minted into a note for `recipient`
333/// in the token's shielded pool. Returns the transition and the fee paid.
334#[allow(clippy::too_many_arguments)]
335pub fn build_token_purchase_from_shielded_pool_transition<P: OrchardProver>(
336    token_id: Identifier,
337    data_contract_id: Identifier,
338    token_contract_position: TokenContractPosition,
339    recipient: &OrchardAddress,
340    recipient_fvk: &FullViewingKey,
341    token_count: TokenAmount,
342    total_agreed_price: Credits,
343    memo: [u8; 36],
344    fee_payer: ShieldedFeePayer<'_>,
345    prover: &P,
346    platform_version: &PlatformVersion,
347) -> Result<(StateTransition, Credits), ProtocolError> {
348    if token_count == 0 {
349        return Err(ProtocolError::ShieldedBuildError(
350            "token purchase count must be greater than zero".to_string(),
351        ));
352    }
353    if token_count > i64::MAX as u64 {
354        return Err(ProtocolError::ShieldedBuildError(format!(
355            "token purchase count {} exceeds maximum allowed value {}",
356            token_count,
357            i64::MAX as u64
358        )));
359    }
360    // An outputs-only bundle carries no spend-auth signatures; its binding signature still
361    // commits to the token id, count and price, so the bundle is proven over that sighash.
362    let token_extra = token_purchase_from_shielded_pool_extra_sighash_data(
363        &token_id.to_buffer(),
364        token_count,
365        total_agreed_price,
366        platform_version,
367    )?;
368    let mut builder = Builder::<DashMemo>::new(
369        BundleType::Transactional {
370            flags: Flags::SPENDS_DISABLED,
371            bundle_required: false,
372        },
373        Anchor::empty_tree(),
374    );
375    builder
376        .add_output(
377            Some(recipient_fvk.to_ovk(Scope::External)),
378            PaymentAddress::from(recipient),
379            NoteValue::from_raw(token_count),
380            memo,
381        )
382        .map_err(|e| ProtocolError::ShieldedBuildError(format!("failed to add output: {:?}", e)))?;
383    let token_sb =
384        serialize_authorized_bundle(&prove_and_sign_bundle(builder, prover, &[], &token_extra)?);
385    if token_sb.value_balance != -(token_count as i64) {
386        return Err(ProtocolError::ShieldedBuildError(format!(
387            "token purchase bundle value balance {} does not equal minus the token count {}",
388            token_sb.value_balance, token_count
389        )));
390    }
391    let fee_actions = fee_payer.spends.len().max(2);
392    let fee = compute_token_purchase_from_shielded_pool_fee(
393        token_sb.actions.len(),
394        fee_actions,
395        platform_version,
396    )?;
397    let credits_leaving = total_agreed_price.checked_add(fee).ok_or_else(|| {
398        ProtocolError::ShieldedBuildError("price + fee overflows u64".to_string())
399    })?;
400    let fee_sb = build_fee_bundle(
401        TOKEN_PURCHASE_FROM_SHIELDED_POOL_TYPE,
402        &token_id,
403        &token_sb.actions,
404        fee_payer,
405        credits_leaving,
406        fee_actions,
407        prover,
408        platform_version,
409    )?;
410    let transition = TokenPurchaseFromShieldedPoolTransition::try_from_bundles(
411        data_contract_id,
412        token_contract_position,
413        token_id,
414        token_count,
415        total_agreed_price,
416        token_sb.actions,
417        token_sb.anchor,
418        token_sb.proof,
419        token_sb.binding_signature,
420        fee_sb.actions,
421        fee_sb.anchor,
422        fee_sb.proof,
423        fee_sb.binding_signature,
424        credits_leaving,
425        platform_version,
426    )?;
427    Ok((transition, fee))
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433    use crate::shielded::builder::test_helpers::{
434        test_orchard_address, test_spendable_note, TestProver,
435    };
436    use grovedb_commitment_tree::SpendingKey;
437
438    fn keys() -> (FullViewingKey, SpendAuthorizingKey) {
439        let sk = SpendingKey::from_bytes([42u8; 32]).expect("valid spending key bytes");
440        (FullViewingKey::from(&sk), SpendAuthorizingKey::from(&sk))
441    }
442
443    #[test]
444    fn rejects_a_transfer_above_the_spendable_token_value() {
445        let (fvk, ask) = keys();
446        let address = test_orchard_address();
447        let err = build_token_shielded_transfer_with_shielded_fee_transition(
448            Identifier::from([1u8; 32]),
449            Identifier::from([2u8; 32]),
450            0,
451            TokenPoolSpender {
452                spends: vec![test_spendable_note(100)],
453                change_address: &address,
454                fvk: &fvk,
455                ask: &ask,
456                anchor: Anchor::empty_tree(),
457            },
458            &address,
459            1_000,
460            [0u8; 36],
461            ShieldedFeePayer {
462                spends: vec![test_spendable_note(1_000_000_000)],
463                change_address: &address,
464                fvk: &fvk,
465                ask: &ask,
466                anchor: Anchor::empty_tree(),
467            },
468            &TestProver,
469            PlatformVersion::latest(),
470        )
471        .expect_err("overspend must be rejected")
472        .to_string();
473        assert!(
474            err.contains("exceeds total spendable value"),
475            "unexpected error: {err}"
476        );
477    }
478
479    #[test]
480    fn rejects_a_zero_unshield_amount() {
481        let (fvk, ask) = keys();
482        let address = test_orchard_address();
483        let err = build_token_unshield_with_shielded_fee_transition(
484            Identifier::from([1u8; 32]),
485            Identifier::from([2u8; 32]),
486            0,
487            TokenPoolSpender {
488                spends: vec![test_spendable_note(100)],
489                change_address: &address,
490                fvk: &fvk,
491                ask: &ask,
492                anchor: Anchor::empty_tree(),
493            },
494            Identifier::from([3u8; 32]),
495            0,
496            [0u8; 36],
497            ShieldedFeePayer {
498                spends: vec![test_spendable_note(1_000_000_000)],
499                change_address: &address,
500                fvk: &fvk,
501                ask: &ask,
502                anchor: Anchor::empty_tree(),
503            },
504            &TestProver,
505            PlatformVersion::latest(),
506        )
507        .expect_err("zero amount must be rejected")
508        .to_string();
509        assert!(err.contains("greater than zero"), "unexpected error: {err}");
510    }
511
512    #[test]
513    fn rejects_a_purchase_count_outside_the_value_balance_range() {
514        let (fvk, ask) = keys();
515        let address = test_orchard_address();
516        let err = build_token_purchase_from_shielded_pool_transition(
517            Identifier::from([1u8; 32]),
518            Identifier::from([2u8; 32]),
519            0,
520            &address,
521            &fvk,
522            1u64 << 63,
523            1_000,
524            [0u8; 36],
525            ShieldedFeePayer {
526                spends: vec![test_spendable_note(1_000_000_000)],
527                change_address: &address,
528                fvk: &fvk,
529                ask: &ask,
530                anchor: Anchor::empty_tree(),
531            },
532            &TestProver,
533            PlatformVersion::latest(),
534        )
535        .expect_err("a count that cannot be negated as i64 must be rejected before proving")
536        .to_string();
537        assert!(
538            err.contains("exceeds maximum allowed value"),
539            "unexpected error: {err}"
540        );
541    }
542
543    #[test]
544    fn rejects_a_purchase_the_fee_notes_cannot_pay() {
545        let (fvk, ask) = keys();
546        let address = test_orchard_address();
547        let err = build_token_purchase_from_shielded_pool_transition(
548            Identifier::from([1u8; 32]),
549            Identifier::from([2u8; 32]),
550            0,
551            &address,
552            &fvk,
553            5,
554            1_000,
555            [0u8; 36],
556            ShieldedFeePayer {
557                spends: vec![test_spendable_note(10)],
558                change_address: &address,
559                fvk: &fvk,
560                ask: &ask,
561                anchor: Anchor::empty_tree(),
562            },
563            &TestProver,
564            PlatformVersion::latest(),
565        )
566        .expect_err("the fee notes cannot cover price plus fee")
567        .to_string();
568        assert!(
569            err.contains("exceed the total spendable value"),
570            "unexpected error: {err}"
571        );
572    }
573}