Skip to main content

sui_types/
coin_reservation.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4//! This module defines the protocol for specifying an address balance reservation
5//! via an ObjectRef, in order to provide backward compatibility for clients that do
6//! not understand address balances.
7//!
8//! The layout of the reservation ObjectRef is as follows:
9//!
10//!    (ObjectID, SequenceNumber, ObjectDigest)
11//!
12//! The ObjectID points to an accumulator object (i.e. a dynamic field of the accumulator root object).
13//! This identifies both the owner and type (e.g. SUI, USDC, etc) of the balance being spent.
14//!
15//! It is masked by XORing with the current chain identifier (i.e. genesis checkpoint digest).
16//! This prevents cross-chain replay, as an attacker would have to mine an address and currency
17//! type such that `dynamic_field_key(address, type) = V` such that
18//! `V ^ FOREIGN_CHAIN_IDENTIFIER = TARGET_ACCUMULATOR_OBJECT_ID ^ TARGET_CHAIN_IDENTIFIER`
19//! and then trick the target into signing a transaction as V on the foreign chain.
20//!
21//! The masking also allows read APIs to positively identify attempts to read a "fake" object ID, as
22//! follows:
23//!   1. First, read the requested object ID.
24//!   2. If it does not exist, unmask the ID using the local chain identifier and read it again.
25//!   3. If it exists on the second attempt, the ID must have originated by masking an accumulator object ID.
26//!
27//! The SequenceNumber is a monotonically increasing version number, typically the version of the
28//! accumulator root object. It is not used by the protocol, but is intended to help the
29//! caching behavior of old clients.
30//!
31//! ObjectDigest contains the remainder of the payload:
32//!
33//! 1. The amount of the reservation [8 bytes]
34//! 2. The epoch(s) in which the tx is valid [4 bytes] (good enough for 12 million years of 24 hour epochs).
35//! 3. A magic number to identify this ObjectRef as a coin reservation [20 bytes].
36
37use std::sync::Arc;
38
39use move_core_types::language_storage::TypeTag;
40use thiserror::Error;
41
42use crate::{
43    accumulator_root::{AccumulatorKey, AccumulatorValue},
44    base_types::{ObjectID, ObjectRef, SequenceNumber, SuiAddress},
45    committee::EpochId,
46    digests::{ChainIdentifier, ObjectDigest},
47    error::{UserInputError, UserInputResult},
48    storage::RuntimeObjectResolver,
49    transaction::FundsWithdrawalArg,
50};
51
52macro_rules! invalid_res_error {
53    ($($args:tt)*) => {
54        UserInputError::InvalidWithdrawReservation {
55            error: format!($($args)*),
56        }
57    };
58}
59
60/// Trait for resolving funds withdrawal from a coin reservation
61pub trait CoinReservationResolverTrait {
62    // Used to check validity of the transaction. If the coin_reservation does not
63    // point to an existing accumulator object, the transaction will be rejected.
64    fn resolve_funds_withdrawal(
65        &self,
66        // Note: must be the sender. We do not support sponsorship.
67        sender: SuiAddress,
68        coin_reservation: ParsedObjectRefWithdrawal,
69        // The version of the accumulator root object to use for MVCC lookup.
70        // If None, use the latest version.
71        accumulator_version: Option<SequenceNumber>,
72    ) -> UserInputResult<FundsWithdrawalArg>;
73}
74
75pub const COIN_RESERVATION_MAGIC: [u8; 20] = [
76    0xac, 0xac, 0xac, 0xac, 0xac, 0xac, 0xac, 0xac, 0xac, 0xac, 0xac, 0xac, 0xac, 0xac, 0xac, 0xac,
77    0xac, 0xac, 0xac, 0xac,
78];
79
80#[derive(Clone, Copy, PartialEq, Eq, Debug)]
81pub struct ParsedDigest {
82    epoch_id: u32,
83    reservation_amount: u64,
84}
85
86impl ParsedDigest {
87    pub fn epoch_id(&self) -> EpochId {
88        self.epoch_id as EpochId
89    }
90
91    pub fn reservation_amount(&self) -> u64 {
92        self.reservation_amount
93    }
94
95    pub fn is_coin_reservation_digest(digest: &ObjectDigest) -> bool {
96        let inner = digest.inner();
97        // check if the last 20 bytes of digest match the magic number
98        let last_20_bytes: &[u8; 20] = inner[12..32].try_into().unwrap();
99        *last_20_bytes == COIN_RESERVATION_MAGIC
100    }
101}
102
103#[derive(Debug, Error)]
104#[error("Invalid digest")]
105pub struct ParsedDigestError;
106
107impl TryFrom<ObjectDigest> for ParsedDigest {
108    type Error = ParsedDigestError;
109
110    fn try_from(digest: ObjectDigest) -> Result<Self, Self::Error> {
111        if ParsedDigest::is_coin_reservation_digest(&digest) {
112            let inner = digest.inner();
113            let reservation_amount_bytes: &[u8; 8] = inner[0..8].try_into().unwrap();
114            let epoch_bytes: &[u8; 4] = inner[8..12].try_into().unwrap();
115
116            let epoch_id = u32::from_le_bytes(*epoch_bytes);
117            let reservation_amount = u64::from_le_bytes(*reservation_amount_bytes);
118
119            Ok(Self {
120                epoch_id,
121                reservation_amount,
122            })
123        } else {
124            Err(ParsedDigestError)
125        }
126    }
127}
128
129impl From<ParsedDigest> for ObjectDigest {
130    fn from(parsed: ParsedDigest) -> Self {
131        let mut inner = [0; 32];
132        inner[0..8].copy_from_slice(&parsed.reservation_amount.to_le_bytes());
133        inner[8..12].copy_from_slice(&parsed.epoch_id.to_le_bytes());
134        inner[12..32].copy_from_slice(&COIN_RESERVATION_MAGIC);
135        ObjectDigest::new(inner)
136    }
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub struct ParsedObjectRefWithdrawal {
141    pub unmasked_object_id: ObjectID,
142    pub parsed_digest: ParsedDigest,
143}
144
145impl ParsedObjectRefWithdrawal {
146    pub fn new(unmasked_object_id: ObjectID, epoch_id: EpochId, reservation_amount: u64) -> Self {
147        Self {
148            unmasked_object_id,
149            parsed_digest: ParsedDigest {
150                epoch_id: epoch_id.try_into().unwrap(),
151                reservation_amount,
152            },
153        }
154    }
155
156    pub fn reservation_amount(&self) -> u64 {
157        self.parsed_digest.reservation_amount()
158    }
159
160    pub fn epoch_id(&self) -> EpochId {
161        self.parsed_digest.epoch_id()
162    }
163
164    pub fn encode(&self, version: SequenceNumber, chain_identifier: ChainIdentifier) -> ObjectRef {
165        let digest = self.parsed_digest.into();
166        let masked_id = mask_or_unmask_id(self.unmasked_object_id, chain_identifier);
167        (masked_id, version, digest)
168    }
169
170    pub fn parse(object_ref: &ObjectRef, chain_identifier: ChainIdentifier) -> Option<Self> {
171        let (object_id, _version, digest) = object_ref;
172        let parsed_digest = ParsedDigest::try_from(*digest).ok()?;
173
174        let unmasked_object_id = mask_or_unmask_id(*object_id, chain_identifier);
175
176        Some(ParsedObjectRefWithdrawal {
177            unmasked_object_id,
178            parsed_digest,
179        })
180    }
181}
182
183pub fn mask_or_unmask_id(object_id: ObjectID, chain_identifier: ChainIdentifier) -> ObjectID {
184    let mask_bytes: &[u8; 32] = chain_identifier.as_bytes();
185
186    let object_id_bytes: [u8; 32] = object_id.into_bytes();
187    let mut masked_object_id_bytes = [0; 32];
188    for i in 0..32 {
189        masked_object_id_bytes[i] = object_id_bytes[i] ^ mask_bytes[i];
190    }
191    ObjectID::new(masked_object_id_bytes)
192}
193
194/// Creates a fake ObjectRef representing an address balance, suitable for returning from
195/// JSON-RPC APIs to backward-compatible clients. The object_id is masked with the chain
196/// identifier to prevent cross-chain replay.
197pub fn encode_object_ref(
198    unmasked_object_id: ObjectID,
199    version: SequenceNumber,
200    epoch: EpochId,
201    balance: u64,
202    chain_identifier: ChainIdentifier,
203) -> ObjectRef {
204    ParsedObjectRefWithdrawal::new(unmasked_object_id, epoch, balance)
205        .encode(version, chain_identifier)
206}
207
208fn get_owner_and_type_for_object_impl(
209    runtime_object_resolver: &dyn RuntimeObjectResolver,
210    object_id: ObjectID,
211    accumulator_version: Option<SequenceNumber>,
212) -> UserInputResult<Option<(SuiAddress, TypeTag)>> {
213    let Some(object) = AccumulatorValue::load_object_by_id(
214        runtime_object_resolver,
215        accumulator_version,
216        object_id,
217    )
218    .map_err(|e| invalid_res_error!("could not load coin reservation object id {}", e))?
219    else {
220        return Ok(None);
221    };
222
223    let move_object = object.data.try_as_move().unwrap();
224
225    let type_tag: TypeTag = move_object
226        .type_()
227        .balance_accumulator_field_type_maybe()
228        .ok_or_else(|| {
229            invalid_res_error!(
230                "coin reservation object id {} is not a balance accumulator field",
231                object_id
232            )
233        })?;
234
235    let (key, _): (AccumulatorKey, AccumulatorValue) = move_object
236        .try_into()
237        .map_err(|e| invalid_res_error!("could not load coin reservation object id {}", e))?;
238
239    Ok(Some((key.owner, type_tag)))
240}
241
242fn resolve_funds_withdrawal_impl(
243    runtime_object_resolver: &dyn RuntimeObjectResolver,
244    sender: SuiAddress,
245    coin_reservation: ParsedObjectRefWithdrawal,
246    accumulator_version: Option<SequenceNumber>,
247) -> UserInputResult<FundsWithdrawalArg> {
248    let (owner, type_tag) = get_owner_and_type_for_object_impl(
249        runtime_object_resolver,
250        coin_reservation.unmasked_object_id,
251        accumulator_version,
252    )?
253    .ok_or_else(|| {
254        invalid_res_error!(
255            "coin reservation object id {} not found",
256            coin_reservation.unmasked_object_id
257        )
258    })?;
259
260    if sender != owner {
261        return Err(invalid_res_error!(
262            "coin reservation object id {} is owned by {}, not sender {}",
263            coin_reservation.unmasked_object_id,
264            owner,
265            sender
266        ));
267    }
268
269    Ok(FundsWithdrawalArg::balance_from_sender(
270        coin_reservation.reservation_amount(),
271        type_tag,
272    ))
273}
274
275/// Resolves coin reservations by looking up the accumulator object to determine
276/// the owner and type of the balance being withdrawn.
277pub struct CoinReservationResolver {
278    runtime_object_resolver: Arc<dyn RuntimeObjectResolver + Send + Sync>,
279}
280
281impl CoinReservationResolver {
282    pub fn new(runtime_object_resolver: Arc<dyn RuntimeObjectResolver + Send + Sync>) -> Self {
283        Self {
284            runtime_object_resolver,
285        }
286    }
287
288    /// Looks up the type tag and owner for a given accumulator object ID.
289    /// Returns `Ok(Some((owner, type_tag)))` if the object exists and is a valid balance
290    /// accumulator field, `Ok(None)` if the object does not exist (a transient condition
291    /// on this node), and `Err(_)` for any other failure (which is permanent for this
292    /// `object_id` and therefore safe to cache).
293    pub fn get_owner_and_type_for_object(
294        &self,
295        object_id: ObjectID,
296        accumulator_version: Option<SequenceNumber>,
297    ) -> UserInputResult<Option<(SuiAddress, TypeTag)>> {
298        get_owner_and_type_for_object_impl(
299            self.runtime_object_resolver.as_ref(),
300            object_id,
301            accumulator_version,
302        )
303    }
304
305    pub fn resolve_funds_withdrawal(
306        &self,
307        sender: SuiAddress,
308        coin_reservation: ParsedObjectRefWithdrawal,
309        accumulator_version: Option<SequenceNumber>,
310    ) -> UserInputResult<FundsWithdrawalArg> {
311        resolve_funds_withdrawal_impl(
312            self.runtime_object_resolver.as_ref(),
313            sender,
314            coin_reservation,
315            accumulator_version,
316        )
317    }
318}
319
320/// Borrow a runtime object resolver for coin-reservation lookups.
321pub struct BorrowedCoinReservationResolver<'a> {
322    runtime_object_resolver: &'a dyn RuntimeObjectResolver,
323}
324
325impl<'a> BorrowedCoinReservationResolver<'a> {
326    /// Create a coin-reservation resolver over borrowed storage.
327    pub fn new(runtime_object_resolver: &'a dyn RuntimeObjectResolver) -> Self {
328        Self {
329            runtime_object_resolver,
330        }
331    }
332}
333
334impl CoinReservationResolverTrait for BorrowedCoinReservationResolver<'_> {
335    fn resolve_funds_withdrawal(
336        &self,
337        sender: SuiAddress,
338        coin_reservation: ParsedObjectRefWithdrawal,
339        accumulator_version: Option<SequenceNumber>,
340    ) -> UserInputResult<FundsWithdrawalArg> {
341        resolve_funds_withdrawal_impl(
342            self.runtime_object_resolver,
343            sender,
344            coin_reservation,
345            accumulator_version,
346        )
347    }
348}
349
350impl CoinReservationResolverTrait for CoinReservationResolver {
351    fn resolve_funds_withdrawal(
352        &self,
353        sender: SuiAddress,
354        coin_reservation: ParsedObjectRefWithdrawal,
355        accumulator_version: Option<SequenceNumber>,
356    ) -> UserInputResult<FundsWithdrawalArg> {
357        CoinReservationResolver::resolve_funds_withdrawal(
358            self,
359            sender,
360            coin_reservation,
361            accumulator_version,
362        )
363    }
364}
365
366impl CoinReservationResolverTrait for &'_ CoinReservationResolver {
367    fn resolve_funds_withdrawal(
368        &self,
369        sender: SuiAddress,
370        coin_reservation: ParsedObjectRefWithdrawal,
371        accumulator_version: Option<SequenceNumber>,
372    ) -> UserInputResult<FundsWithdrawalArg> {
373        CoinReservationResolver::resolve_funds_withdrawal(
374            self,
375            sender,
376            coin_reservation,
377            accumulator_version,
378        )
379    }
380}
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    #[test]
386    fn test_parse_normal_digest() {
387        let digest = ObjectDigest::new([0; 32]);
388        assert!(ParsedDigest::try_from(digest).is_err());
389    }
390
391    #[test]
392    fn test_is_coin_reservation_digest() {
393        let digest = ObjectDigest::random();
394        assert!(!ParsedDigest::is_coin_reservation_digest(&digest));
395
396        let digest = ParsedDigest {
397            epoch_id: 42,
398            reservation_amount: 1232348999,
399        }
400        .into();
401        assert!(ParsedDigest::is_coin_reservation_digest(&digest));
402    }
403
404    #[test]
405    fn test_encode_and_parse_digest() {
406        let parsed_digest = ParsedDigest {
407            epoch_id: 42,
408            reservation_amount: 1232348999,
409        };
410
411        let digest = ObjectDigest::from(parsed_digest);
412        assert_eq!(parsed_digest, ParsedDigest::try_from(digest).unwrap());
413    }
414
415    #[test]
416    fn test_parse_object_ref() {
417        let object_ref = (
418            ObjectID::new([0; 32]),
419            SequenceNumber::new(),
420            ObjectDigest::new([0; 32]),
421        );
422
423        assert!(
424            ParsedObjectRefWithdrawal::parse(&object_ref, ChainIdentifier::default()).is_none()
425        );
426    }
427
428    #[test]
429    fn test_borrowed_resolver_uses_runtime_object_resolver() {
430        let store = crate::in_memory_storage::InMemoryStorage::default();
431        let resolver = BorrowedCoinReservationResolver::new(&store);
432        let object_id = ObjectID::random();
433        let result = resolver.resolve_funds_withdrawal(
434            SuiAddress::random_for_testing_only(),
435            ParsedObjectRefWithdrawal::new(object_id, 0, 1),
436            None,
437        );
438
439        assert!(matches!(
440            result,
441            Err(UserInputError::InvalidWithdrawReservation { error })
442                if error == format!("coin reservation object id {object_id} not found")
443        ));
444    }
445
446    #[test]
447    fn test_parse_object_ref_with_valid_digest() {
448        let chain_id = ChainIdentifier::random();
449
450        let id = ObjectID::random();
451        let parsed_obj_ref = ParsedObjectRefWithdrawal {
452            unmasked_object_id: id,
453            parsed_digest: ParsedDigest {
454                epoch_id: 42,
455                reservation_amount: 1232348999,
456            },
457        };
458        let encoded_obj_ref = parsed_obj_ref.encode(SequenceNumber::new(), chain_id);
459
460        assert_ne!(encoded_obj_ref.0, id, "object id should be masked");
461
462        let parsed_obj_ref = ParsedObjectRefWithdrawal::parse(&encoded_obj_ref, chain_id).unwrap();
463        assert_eq!(parsed_obj_ref.unmasked_object_id, id);
464        assert_eq!(parsed_obj_ref.parsed_digest.epoch_id, 42);
465        assert_eq!(parsed_obj_ref.parsed_digest.reservation_amount, 1232348999);
466    }
467}