Skip to main content

dpp/shielded/builder/
token_unshield.rs

1use grovedb_commitment_tree::{Anchor, FullViewingKey, SpendAuthorizingKey};
2
3use crate::address_funds::OrchardAddress;
4use crate::balances::credits::TokenAmount;
5use crate::identity::signer::Signer;
6use crate::identity::IdentityPublicKey;
7use crate::prelude::{Identifier, IdentityNonce, UserFeeIncrease};
8use crate::shielded::{token_unshield_extra_sighash_data, OrchardBundleParams};
9use crate::state_transition::batch_transition::methods::v1::DocumentsBatchTransitionMethodsV1;
10use crate::state_transition::batch_transition::BatchTransition;
11use crate::state_transition::StateTransition;
12use crate::ProtocolError;
13use platform_version::version::PlatformVersion;
14
15use super::{build_spend_bundle, serialize_authorized_bundle, OrchardProver, SpendableNote};
16
17/// Builds a `TokenUnshield` batch transition: spends `spends` from the token's shielded pool,
18/// credits `amount` tokens to `recipient_id`, returns the remainder to `change_address` as a
19/// new note, and wraps the bundle in a batch transition signed by `owner_id`.
20///
21/// Tokens cannot pay fees, so unlike the credit pool's `Unshield` nothing is carved from the
22/// value balance: `value_balance == amount` exactly, and the signing identity pays the fee in
23/// credits. The token id, owner id, recipient id and amount are bound into the Orchard sighash
24/// so the bundle cannot be replayed against a different token, recipient or amount.
25#[allow(clippy::too_many_arguments)]
26pub async fn build_token_unshield_transition<S: Signer<IdentityPublicKey>, P: OrchardProver>(
27    token_id: Identifier,
28    owner_id: Identifier,
29    data_contract_id: Identifier,
30    token_contract_position: u16,
31    spends: Vec<SpendableNote>,
32    recipient_id: Identifier,
33    amount: TokenAmount,
34    change_address: &OrchardAddress,
35    fvk: &FullViewingKey,
36    ask: &SpendAuthorizingKey,
37    anchor: Anchor,
38    memo: [u8; 36],
39    identity_public_key: &IdentityPublicKey,
40    identity_contract_nonce: IdentityNonce,
41    user_fee_increase: UserFeeIncrease,
42    signer: &S,
43    prover: &P,
44    platform_version: &PlatformVersion,
45) -> Result<StateTransition, ProtocolError> {
46    if amount == 0 {
47        return Err(ProtocolError::ShieldedBuildError(
48            "token unshield amount must be greater than zero".to_string(),
49        ));
50    }
51    if amount > i64::MAX as u64 {
52        return Err(ProtocolError::ShieldedBuildError(format!(
53            "token unshield amount {} exceeds maximum allowed value {}",
54            amount,
55            i64::MAX as u64
56        )));
57    }
58
59    let total_spent: u64 = spends
60        .iter()
61        .try_fold(0u64, |total, spend| {
62            total.checked_add(spend.note.value().inner())
63        })
64        .ok_or_else(|| {
65            ProtocolError::ShieldedBuildError("total spendable value overflows u64".to_string())
66        })?;
67    if amount > total_spent {
68        return Err(ProtocolError::ShieldedBuildError(format!(
69            "token unshield amount {} exceeds total spendable value {}",
70            amount, total_spent
71        )));
72    }
73    let change_amount = total_spent - amount;
74
75    let extra_sighash_data = token_unshield_extra_sighash_data(
76        &token_id.to_buffer(),
77        &owner_id.to_buffer(),
78        &recipient_id.to_buffer(),
79        amount,
80        platform_version,
81    )?;
82
83    let bundle = build_spend_bundle(
84        spends,
85        change_address,
86        change_amount,
87        memo,
88        fvk,
89        ask,
90        anchor,
91        prover,
92        &extra_sighash_data,
93    )?;
94    let sb = serialize_authorized_bundle(&bundle);
95
96    if sb.value_balance != amount as i64 {
97        return Err(ProtocolError::ShieldedBuildError(format!(
98            "token unshield bundle value balance {} does not equal the amount {}",
99            sb.value_balance, amount
100        )));
101    }
102
103    BatchTransition::new_token_unshield_transition(
104        token_id,
105        owner_id,
106        data_contract_id,
107        token_contract_position,
108        amount,
109        recipient_id,
110        OrchardBundleParams {
111            actions: sb.actions,
112            anchor: sb.anchor,
113            proof: sb.proof,
114            binding_signature: sb.binding_signature,
115        },
116        identity_public_key,
117        identity_contract_nonce,
118        user_fee_increase,
119        signer,
120        platform_version,
121        None,
122    )
123    .await
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use crate::shielded::builder::test_helpers::{
130        test_identity_key, test_orchard_address, test_spendable_note, DummyIdentitySigner,
131        TestProver,
132    };
133    use grovedb_commitment_tree::SpendingKey;
134
135    fn keys() -> (FullViewingKey, SpendAuthorizingKey) {
136        let sk = SpendingKey::from_bytes([42u8; 32]).expect("valid spending key bytes");
137        (FullViewingKey::from(&sk), SpendAuthorizingKey::from(&sk))
138    }
139
140    #[tokio::test]
141    async fn rejects_amount_above_spendable_value() {
142        let (fvk, ask) = keys();
143        let key = test_identity_key();
144        let err = build_token_unshield_transition(
145            Identifier::from([1u8; 32]),
146            Identifier::from([2u8; 32]),
147            Identifier::from([3u8; 32]),
148            0,
149            vec![test_spendable_note(100)],
150            Identifier::from([4u8; 32]),
151            1_000,
152            &test_orchard_address(),
153            &fvk,
154            &ask,
155            Anchor::empty_tree(),
156            [0u8; 36],
157            &key,
158            1,
159            0,
160            &DummyIdentitySigner,
161            &TestProver,
162            PlatformVersion::latest(),
163        )
164        .await
165        .expect_err("overspend must be rejected")
166        .to_string();
167        assert!(
168            err.contains("exceeds total spendable value"),
169            "unexpected error: {err}"
170        );
171    }
172
173    #[tokio::test]
174    async fn rejects_zero_amount() {
175        let (fvk, ask) = keys();
176        let key = test_identity_key();
177        let err = build_token_unshield_transition(
178            Identifier::from([1u8; 32]),
179            Identifier::from([2u8; 32]),
180            Identifier::from([3u8; 32]),
181            0,
182            vec![test_spendable_note(100)],
183            Identifier::from([4u8; 32]),
184            0,
185            &test_orchard_address(),
186            &fvk,
187            &ask,
188            Anchor::empty_tree(),
189            [0u8; 36],
190            &key,
191            1,
192            0,
193            &DummyIdentitySigner,
194            &TestProver,
195            PlatformVersion::latest(),
196        )
197        .await
198        .expect_err("zero amount must be rejected")
199        .to_string();
200        assert!(err.contains("greater than zero"), "unexpected error: {err}");
201    }
202}