iota_types/timelock/
timelock.rs

1// Copyright (c) 2024 IOTA Stiftung
2// SPDX-License-Identifier: Apache-2.0
3
4use iota_protocol_config::ProtocolConfig;
5use iota_stardust_sdk::types::block::output::{BasicOutput, OutputId};
6use move_core_types::{
7    ident_str,
8    identifier::IdentStr,
9    language_storage::{StructTag, TypeTag},
10};
11use serde::{Deserialize, Serialize};
12
13use super::{
14    label::label_struct_tag_to_string, stardust_upgrade_label::stardust_upgrade_label_type,
15};
16use crate::{
17    IOTA_FRAMEWORK_ADDRESS,
18    balance::Balance,
19    base_types::{IotaAddress, MoveObjectType, ObjectID, SequenceNumber, TxContext},
20    error::{ExecutionError, IotaError},
21    gas_coin::GasCoin,
22    id::UID,
23    object::{Data, MoveObject, Object, Owner},
24};
25
26#[cfg(test)]
27#[path = "../unit_tests/timelock/timelock_tests.rs"]
28mod timelock_tests;
29
30pub const TIMELOCK_MODULE_NAME: &IdentStr = ident_str!("timelock");
31pub const TIMELOCK_STRUCT_NAME: &IdentStr = ident_str!("TimeLock");
32
33/// All basic outputs whose IDs start with this prefix represent vested rewards
34/// that were created during the stardust upgrade on IOTA mainnet.
35pub const VESTED_REWARD_ID_PREFIX: &str =
36    "0xb191c4bc825ac6983789e50545d5ef07a1d293a98ad974fc9498cb18";
37
38#[derive(Debug, thiserror::Error)]
39pub enum VestedRewardError {
40    #[error("failed to create genesis move object, owner: {owner}, timelock: {timelock:#?}")]
41    ObjectCreation {
42        owner: IotaAddress,
43        timelock: TimeLock<Balance>,
44        source: ExecutionError,
45    },
46    #[error("a vested reward must not contain native tokens")]
47    NativeTokensNotSupported,
48    #[error("a basic output is not a vested reward")]
49    NotVestedReward,
50    #[error("a vested reward must have two unlock conditions")]
51    UnlockConditionsNumberMismatch,
52    #[error("only timelocked vested rewards can be migrated as `TimeLock<Balance<IOTA>>`")]
53    UnlockedVestedReward,
54}
55
56/// Checks if an output is a timelocked vested reward.
57pub fn is_timelocked_vested_reward(
58    output_id: OutputId,
59    basic_output: &BasicOutput,
60    target_milestone_timestamp_sec: u32,
61) -> bool {
62    is_vested_reward(output_id, basic_output)
63        && basic_output
64            .unlock_conditions()
65            .is_time_locked(target_milestone_timestamp_sec)
66}
67
68/// Checks if an output is a vested reward, if it has a specific ID prefix,
69/// and if it contains a timelock unlock condition,
70/// and if an output has no native tokens,
71/// and if an output has only 2 unlock conditions and their address.
72pub fn is_vested_reward(output_id: OutputId, basic_output: &BasicOutput) -> bool {
73    let has_vesting_prefix = output_id.to_string().starts_with(VESTED_REWARD_ID_PREFIX);
74
75    has_vesting_prefix
76        && basic_output.unlock_conditions().timelock().is_some()
77        && basic_output.native_tokens().is_empty()
78        && basic_output.unlock_conditions().len() == 2
79        && basic_output.unlock_conditions().address().is_some()
80}
81
82/// Creates a `TimeLock<Balance<IOTA>>` from a Stardust-based Basic Output
83/// that represents a vested reward.
84pub fn try_from_stardust(
85    output_id: OutputId,
86    basic_output: &BasicOutput,
87    target_milestone_timestamp_sec: u32,
88) -> Result<TimeLock<Balance>, VestedRewardError> {
89    if !is_vested_reward(output_id, basic_output) {
90        return Err(VestedRewardError::NotVestedReward);
91    }
92
93    if !basic_output
94        .unlock_conditions()
95        .is_time_locked(target_milestone_timestamp_sec)
96    {
97        return Err(VestedRewardError::UnlockedVestedReward);
98    }
99
100    if basic_output.unlock_conditions().len() != 2 {
101        return Err(VestedRewardError::UnlockConditionsNumberMismatch);
102    }
103
104    if !basic_output.native_tokens().is_empty() {
105        return Err(VestedRewardError::NativeTokensNotSupported);
106    }
107
108    let id = UID::new(ObjectID::new(output_id.hash()));
109    let locked = Balance::new(basic_output.amount());
110
111    // We already checked the existence of the timelock unlock condition at this
112    // point.
113    let timelock_uc = basic_output
114        .unlock_conditions()
115        .timelock()
116        .expect("a vested reward should contain a timelock unlock condition");
117    let expiration_timestamp_ms = Into::<u64>::into(timelock_uc.timestamp()) * 1000;
118
119    let label = Option::Some(label_struct_tag_to_string(stardust_upgrade_label_type()));
120
121    Ok(TimeLock::new(id, locked, expiration_timestamp_ms, label))
122}
123
124/// Creates a genesis object from a time-locked balance.
125pub fn to_genesis_object(
126    timelock: TimeLock<Balance>,
127    owner: IotaAddress,
128    protocol_config: &ProtocolConfig,
129    tx_context: &TxContext,
130    version: SequenceNumber,
131) -> Result<Object, VestedRewardError> {
132    let move_object = {
133        MoveObject::new_from_execution(
134            MoveObjectType::timelocked_iota_balance(),
135            version,
136            timelock.to_bcs_bytes(),
137            protocol_config,
138        )
139        .map_err(|source| VestedRewardError::ObjectCreation {
140            owner,
141            timelock,
142            source,
143        })?
144    };
145
146    Ok(Object::new_from_genesis(
147        Data::Move(move_object),
148        Owner::AddressOwner(owner),
149        tx_context.digest(),
150    ))
151}
152
153/// Rust version of the Move stardust::TimeLock type.
154#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)]
155pub struct TimeLock<T> {
156    id: UID,
157    /// The locked object.
158    locked: T,
159    /// This is the epoch time stamp of when the lock expires.
160    expiration_timestamp_ms: u64,
161    /// Timelock related label.
162    label: Option<String>,
163}
164
165impl<T> TimeLock<T> {
166    /// Constructor.
167    pub fn new(id: UID, locked: T, expiration_timestamp_ms: u64, label: Option<String>) -> Self {
168        Self {
169            id,
170            locked,
171            expiration_timestamp_ms,
172            label,
173        }
174    }
175
176    /// Get the TimeLock's `type`.
177    pub fn type_(type_param: TypeTag) -> StructTag {
178        StructTag {
179            address: IOTA_FRAMEWORK_ADDRESS,
180            module: TIMELOCK_MODULE_NAME.to_owned(),
181            name: TIMELOCK_STRUCT_NAME.to_owned(),
182            type_params: vec![type_param],
183        }
184    }
185
186    /// Get the TimeLock's `id`.
187    pub fn id(&self) -> &ObjectID {
188        self.id.object_id()
189    }
190
191    /// Get the TimeLock's `locked` object.
192    pub fn locked(&self) -> &T {
193        &self.locked
194    }
195
196    /// Get the TimeLock's `expiration_timestamp_ms`.
197    pub fn expiration_timestamp_ms(&self) -> u64 {
198        self.expiration_timestamp_ms
199    }
200
201    /// Get the TimeLock's `label``.
202    pub fn label(&self) -> &Option<String> {
203        &self.label
204    }
205}
206
207impl<'de, T> TimeLock<T>
208where
209    T: Serialize + Deserialize<'de>,
210{
211    /// Create a `TimeLock` from BCS bytes.
212    pub fn from_bcs_bytes(content: &'de [u8]) -> Result<Self, IotaError> {
213        bcs::from_bytes(content).map_err(|err| IotaError::ObjectDeserialization {
214            error: format!("Unable to deserialize TimeLock object: {:?}", err),
215        })
216    }
217
218    /// Serialize a `TimeLock` as a `Vec<u8>` of BCS.
219    pub fn to_bcs_bytes(&self) -> Vec<u8> {
220        bcs::to_bytes(&self).unwrap()
221    }
222}
223
224/// Is this other StructTag representing a TimeLock?
225pub fn is_timelock(other: &StructTag) -> bool {
226    other.address == IOTA_FRAMEWORK_ADDRESS
227        && other.module.as_ident_str() == TIMELOCK_MODULE_NAME
228        && other.name.as_ident_str() == TIMELOCK_STRUCT_NAME
229}
230
231/// Is this other StructTag representing a `TimeLock<Balance<T>>`?
232pub fn is_timelocked_balance(other: &StructTag) -> bool {
233    if !is_timelock(other) {
234        return false;
235    }
236
237    if other.type_params.len() != 1 {
238        return false;
239    }
240
241    match &other.type_params[0] {
242        TypeTag::Struct(tag) => Balance::is_balance(tag),
243        _ => false,
244    }
245}
246
247/// Is this other StructTag representing a `TimeLock<Balance<IOTA>>`?
248pub fn is_timelocked_gas_balance(other: &StructTag) -> bool {
249    if !is_timelock(other) {
250        return false;
251    }
252
253    if other.type_params.len() != 1 {
254        return false;
255    }
256
257    match &other.type_params[0] {
258        TypeTag::Struct(tag) => GasCoin::is_gas_balance(tag),
259        _ => false,
260    }
261}
262
263impl<'de, T> TryFrom<&'de Object> for TimeLock<T>
264where
265    T: Serialize + Deserialize<'de>,
266{
267    type Error = IotaError;
268
269    fn try_from(object: &'de Object) -> Result<Self, Self::Error> {
270        match &object.data {
271            Data::Move(o) => {
272                if o.type_().is_timelock() {
273                    return TimeLock::from_bcs_bytes(o.contents());
274                }
275            }
276            Data::Package(_) => {}
277        }
278
279        Err(IotaError::Type {
280            error: format!("Object type is not a TimeLock: {:?}", object),
281        })
282    }
283}