identity_iota_core/rebased/assets/
asset.rs

1// Copyright 2020-2024 IOTA Stiftung
2// SPDX-License-Identifier: Apache-2.0
3
4use std::str::FromStr as _;
5
6use crate::rebased::client::IdentityClientReadOnly;
7use crate::rebased::iota::move_calls;
8
9use crate::rebased::Error;
10use anyhow::anyhow;
11use anyhow::Context;
12use async_trait::async_trait;
13
14use iota_interaction::ident_str;
15use iota_interaction::move_types::language_storage::StructTag;
16use iota_interaction::rpc_types::IotaData as _;
17use iota_interaction::rpc_types::IotaExecutionStatus;
18use iota_interaction::rpc_types::IotaObjectDataOptions;
19use iota_interaction::rpc_types::IotaTransactionBlockEffects;
20use iota_interaction::rpc_types::IotaTransactionBlockEffectsAPI as _;
21use iota_interaction::types::base_types::IotaAddress;
22use iota_interaction::types::base_types::ObjectID;
23use iota_interaction::types::base_types::ObjectRef;
24use iota_interaction::types::base_types::SequenceNumber;
25use iota_interaction::types::id::UID;
26use iota_interaction::types::object::Owner;
27use iota_interaction::types::transaction::ProgrammableTransaction;
28use iota_interaction::types::TypeTag;
29use iota_interaction::IotaClientTrait;
30use iota_interaction::IotaTransactionBlockEffectsMutAPI as _;
31use iota_interaction::MoveType;
32use iota_interaction::OptionalSync;
33use product_common::core_client::CoreClientReadOnly;
34use product_common::transaction::transaction_builder::Transaction;
35use product_common::transaction::transaction_builder::TransactionBuilder;
36use serde::de::DeserializeOwned;
37use serde::Deserialize;
38use serde::Deserializer;
39use serde::Serialize;
40use tokio::sync::OnceCell;
41
42/// An on-chain asset that carries information about its owner and its creator.
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44pub struct AuthenticatedAsset<T> {
45  id: UID,
46  #[serde(
47    deserialize_with = "deserialize_inner",
48    bound(deserialize = "T: for<'a> Deserialize<'a>")
49  )]
50  inner: T,
51  owner: IotaAddress,
52  origin: IotaAddress,
53  mutable: bool,
54  transferable: bool,
55  deletable: bool,
56}
57
58fn deserialize_inner<'de, D, T>(deserializer: D) -> Result<T, D::Error>
59where
60  D: Deserializer<'de>,
61  T: for<'a> Deserialize<'a>,
62{
63  use serde::de::Error as _;
64
65  match std::any::type_name::<T>() {
66    "u8" | "u16" | "u32" | "u64" | "u128" | "usize" | "i8" | "i16" | "i32" | "i64" | "i128" | "isize" => {
67      String::deserialize(deserializer).and_then(|s| serde_json::from_str(&s).map_err(D::Error::custom))
68    }
69    _ => T::deserialize(deserializer),
70  }
71}
72
73impl<T> AuthenticatedAsset<T>
74where
75  T: DeserializeOwned,
76{
77  /// Resolves an [`AuthenticatedAsset`] by its ID `id`.
78  pub async fn get_by_id(id: ObjectID, client: &impl CoreClientReadOnly) -> Result<Self, Error> {
79    let res = client
80      .client_adapter()
81      .read_api()
82      .get_object_with_options(id, IotaObjectDataOptions::new().with_content())
83      .await?;
84    let Some(data) = res.data else {
85      return Err(Error::ObjectLookup(res.error.map_or(String::new(), |e| e.to_string())));
86    };
87    data
88      .content
89      .ok_or_else(|| anyhow!("No content for object with ID {id}"))
90      .and_then(|content| content.try_into_move().context("not a Move object"))
91      .and_then(|obj_data| {
92        serde_json::from_value(obj_data.fields.to_json_value()).context("failed to deserialize move object")
93      })
94      .map_err(|e| Error::ObjectLookup(e.to_string()))
95  }
96}
97
98impl<T: MoveType + Send + Sync> AuthenticatedAsset<T> {
99  async fn object_ref(&self, client: &impl CoreClientReadOnly) -> Result<ObjectRef, Error> {
100    client
101      .client_adapter()
102      .read_api()
103      .get_object_with_options(self.id(), IotaObjectDataOptions::default())
104      .await?
105      .object_ref_if_exists()
106      .ok_or_else(|| Error::ObjectLookup("missing object reference in response".to_owned()))
107  }
108
109  /// Returns this [`AuthenticatedAsset`]'s ID.
110  pub fn id(&self) -> ObjectID {
111    *self.id.object_id()
112  }
113
114  /// Returns a reference to this [`AuthenticatedAsset`]'s content.
115  pub fn content(&self) -> &T {
116    &self.inner
117  }
118
119  /// Transfers ownership of this [`AuthenticatedAsset`] to `recipient`.
120  /// # Notes
121  /// This function doesn't perform the transfer right away, but instead creates a [`Transaction`] that
122  /// can be executed to carry out the transfer.
123  /// # Failures
124  /// * Returns an [`Error::InvalidConfig`] if this asset is not transferable.
125  pub fn transfer(
126    self,
127    recipient: IotaAddress,
128    client: &IdentityClientReadOnly,
129  ) -> Result<TransactionBuilder<TransferAsset<T>>, Error> {
130    if !self.transferable {
131      return Err(Error::InvalidConfig(format!(
132        "`AuthenticatedAsset` {} is not transferable",
133        self.id()
134      )));
135    }
136    Ok(TransactionBuilder::new(TransferAsset::new(self, recipient, client)))
137  }
138
139  /// Destroys this [`AuthenticatedAsset`].
140  /// # Notes
141  /// This function doesn't delete the asset right away, but instead creates a [`Transaction`] that
142  /// can be executed in order to destroy the asset.
143  /// # Failures
144  /// * Returns an [`Error::InvalidConfig`] if this asset cannot be deleted.
145  pub fn delete(self, client: &IdentityClientReadOnly) -> Result<TransactionBuilder<DeleteAsset<T>>, Error> {
146    if !self.deletable {
147      return Err(Error::InvalidConfig(format!(
148        "`AuthenticatedAsset` {} cannot be deleted",
149        self.id()
150      )));
151    }
152
153    Ok(TransactionBuilder::new(DeleteAsset::new(self, client)))
154  }
155
156  /// Changes this [`AuthenticatedAsset`]'s content.
157  /// # Notes
158  /// This function doesn't update the asset right away, but instead creates a [`Transaction`] that
159  /// can be executed in order to update the asset's content.
160  /// # Failures
161  /// * Returns an [`Error::InvalidConfig`] if this asset cannot be updated.
162  pub fn set_content(
163    &mut self,
164    new_content: T,
165    client: &IdentityClientReadOnly,
166  ) -> Result<TransactionBuilder<UpdateContent<'_, T>>, Error> {
167    if !self.mutable {
168      return Err(Error::InvalidConfig(format!(
169        "`AuthenticatedAsset` {} is immutable",
170        self.id()
171      )));
172    }
173
174    Ok(TransactionBuilder::new(UpdateContent::new(self, new_content, client)))
175  }
176}
177
178/// Builder-style struct to ease the creation of a new [`AuthenticatedAsset`].
179#[derive(Debug)]
180pub struct AuthenticatedAssetBuilder<T> {
181  inner: T,
182  mutable: bool,
183  transferable: bool,
184  deletable: bool,
185}
186
187impl<T: MoveType> MoveType for AuthenticatedAsset<T> {
188  fn move_type(package: ObjectID) -> TypeTag {
189    TypeTag::Struct(Box::new(StructTag {
190      address: package.into(),
191      module: ident_str!("asset").into(),
192      name: ident_str!("AuthenticatedAsset").into(),
193      type_params: vec![T::move_type(package)],
194    }))
195  }
196}
197
198impl<T> AuthenticatedAssetBuilder<T>
199where
200  T: MoveType + Send + Sync + DeserializeOwned + PartialEq,
201{
202  /// Initializes the builder with the asset's content.
203  pub fn new(content: T) -> Self {
204    Self {
205      inner: content,
206      mutable: false,
207      transferable: false,
208      deletable: false,
209    }
210  }
211
212  /// Sets whether the new asset allows for its modification.
213  ///
214  /// By default an [`AuthenticatedAsset`] is **immutable**.
215  pub fn mutable(mut self, mutable: bool) -> Self {
216    self.mutable = mutable;
217    self
218  }
219
220  /// Sets whether the new asset allows the transfer of its ownership.
221  ///
222  /// By default an [`AuthenticatedAsset`] **cannot** be transferred.
223  pub fn transferable(mut self, transferable: bool) -> Self {
224    self.transferable = transferable;
225    self
226  }
227
228  /// Sets whether the new asset can be deleted.
229  ///
230  /// By default an [`AuthenticatedAsset`] **cannot** be deleted.
231  pub fn deletable(mut self, deletable: bool) -> Self {
232    self.deletable = deletable;
233    self
234  }
235
236  /// Returns a [`Transaction`] that will create the specified [`AuthenticatedAsset`] when executed.
237  pub fn finish(self, client: &IdentityClientReadOnly) -> TransactionBuilder<CreateAsset<T>> {
238    TransactionBuilder::new(CreateAsset::new(self, client))
239  }
240}
241
242/// Proposal for the transfer of an [`AuthenticatedAsset`]'s ownership from one [`IotaAddress`] to another.
243///
244/// # Detailed Workflow
245/// A [`TransferProposal`] is a **shared** _Move_ object that represents a request to transfer ownership
246/// of an [`AuthenticatedAsset`] to a new owner.
247///
248/// When a [`TransferProposal`] is created, it will seize the asset and send a `SenderCap` token to the current asset's
249/// owner and a `RecipientCap` to the specified `recipient` address.
250/// `recipient` can accept the transfer by presenting its `RecipientCap` (this prevents other users from claiming the
251/// asset for themselves).
252/// The current owner can cancel the proposal at any time - given the transfer hasn't been concluded yet - by presenting
253/// its `SenderCap`.
254#[derive(Debug, Clone, Serialize, Deserialize)]
255pub struct TransferProposal {
256  id: UID,
257  asset_id: ObjectID,
258  sender_cap_id: ObjectID,
259  sender_address: IotaAddress,
260  recipient_cap_id: ObjectID,
261  recipient_address: IotaAddress,
262  done: bool,
263}
264
265impl MoveType for TransferProposal {
266  fn move_type(package: ObjectID) -> TypeTag {
267    TypeTag::Struct(Box::new(StructTag {
268      address: package.into(),
269      module: ident_str!("asset").into(),
270      name: ident_str!("TransferProposal").into(),
271      type_params: vec![],
272    }))
273  }
274}
275
276impl TransferProposal {
277  /// Resolves a [`TransferProposal`] by its ID `id`.
278  pub async fn get_by_id(id: ObjectID, client: &impl CoreClientReadOnly) -> Result<Self, Error> {
279    let res = client
280      .client_adapter()
281      .read_api()
282      .get_object_with_options(id, IotaObjectDataOptions::new().with_content())
283      .await?;
284    let Some(data) = res.data else {
285      return Err(Error::ObjectLookup(res.error.map_or(String::new(), |e| e.to_string())));
286    };
287    data
288      .content
289      .ok_or_else(|| anyhow!("No content for object with ID {id}"))
290      .and_then(|content| content.try_into_move().context("not a Move object"))
291      .and_then(|obj_data| {
292        serde_json::from_value(obj_data.fields.to_json_value()).context("failed to deserialize move object")
293      })
294      .map_err(|e| Error::ObjectLookup(e.to_string()))
295  }
296
297  async fn get_cap<C>(&self, cap_type: &str, client: &C) -> Result<ObjectRef, Error>
298  where
299    C: CoreClientReadOnly + OptionalSync,
300  {
301    let cap_tag = StructTag::from_str(&format!("{}::asset::{cap_type}", client.package_id()))
302      .map_err(|e| Error::ParsingFailed(e.to_string()))?;
303    let owner_address = match cap_type {
304      "SenderCap" => self.sender_address,
305      "RecipientCap" => self.recipient_address,
306      _ => unreachable!(),
307    };
308    client
309      .find_owned_ref_for_address(owner_address, cap_tag, |obj_data| {
310        cap_type == "SenderCap" && self.sender_cap_id == obj_data.object_id
311          || cap_type == "RecipientCap" && self.recipient_cap_id == obj_data.object_id
312      })
313      .await?
314      .ok_or_else(|| {
315        Error::MissingPermission(format!(
316          "no owned `{cap_type}` for transfer proposal {}",
317          self.id.object_id(),
318        ))
319      })
320  }
321
322  async fn asset_metadata(&self, client: &impl CoreClientReadOnly) -> anyhow::Result<(ObjectRef, TypeTag)> {
323    let res = client
324      .client_adapter()
325      .read_api()
326      .get_object_with_options(self.asset_id, IotaObjectDataOptions::default().with_type())
327      .await?;
328    let asset_ref = res
329      .object_ref_if_exists()
330      .context("missing object reference in response")?;
331    let param_type = res
332      .data
333      .context("missing data")
334      .and_then(|data| data.type_.context("missing type"))
335      .and_then(StructTag::try_from)
336      .and_then(|mut tag| {
337        if tag.type_params.is_empty() {
338          anyhow::bail!("no type parameter")
339        } else {
340          Ok(tag.type_params.remove(0))
341        }
342      })?;
343
344    Ok((asset_ref, param_type))
345  }
346
347  async fn initial_shared_version(&self, client: &impl CoreClientReadOnly) -> anyhow::Result<SequenceNumber> {
348    let owner = client
349      .client_adapter()
350      .read_api()
351      .get_object_with_options(*self.id.object_id(), IotaObjectDataOptions::default().with_owner())
352      .await?
353      .owner()
354      .context("missing owner information")?;
355    match owner {
356      Owner::Shared { initial_shared_version } => Ok(initial_shared_version),
357      _ => anyhow::bail!("`TransferProposal` is not a shared object"),
358    }
359  }
360
361  /// Accepts this [`TransferProposal`].
362  /// # Warning
363  /// This operation only has an effects when it's invoked by this [`TransferProposal`]'s `recipient`.
364  pub fn accept(self, client: &IdentityClientReadOnly) -> TransactionBuilder<AcceptTransfer> {
365    TransactionBuilder::new(AcceptTransfer::new(self, client))
366  }
367
368  /// Concludes or cancels this [`TransferProposal`].
369  /// # Warning
370  /// * This operation only has an effects when it's invoked by this [`TransferProposal`]'s `sender`.
371  /// * Accepting a [`TransferProposal`] **doesn't** consume it from the ledger. This function must be used to correctly
372  ///   consume both [`TransferProposal`] and `SenderCap`.
373  pub fn conclude_or_cancel(self, client: &IdentityClientReadOnly) -> TransactionBuilder<ConcludeTransfer> {
374    TransactionBuilder::new(ConcludeTransfer::new(self, client))
375  }
376
377  /// Returns this [`TransferProposal`]'s ID.
378  pub fn id(&self) -> ObjectID {
379    *self.id.object_id()
380  }
381
382  /// Returns this [`TransferProposal`]'s `sender`'s address.
383  pub fn sender(&self) -> IotaAddress {
384    self.sender_address
385  }
386
387  /// Returns this [`TransferProposal`]'s `recipient`'s address.
388  pub fn recipient(&self) -> IotaAddress {
389    self.recipient_address
390  }
391
392  /// Returns `true` if this [`TransferProposal`] is concluded.
393  pub fn is_concluded(&self) -> bool {
394    self.done
395  }
396}
397
398/// A [`Transaction`] that updates an [`AuthenticatedAsset`]'s content.
399#[derive(Debug)]
400pub struct UpdateContent<'a, T> {
401  asset: &'a mut AuthenticatedAsset<T>,
402  new_content: T,
403  cached_ptb: OnceCell<ProgrammableTransaction>,
404  package: ObjectID,
405}
406
407impl<'a, T: MoveType + Send + Sync> UpdateContent<'a, T> {
408  /// Returns a [Transaction] to update the content of `asset`.
409  pub fn new(asset: &'a mut AuthenticatedAsset<T>, new_content: T, client: &IdentityClientReadOnly) -> Self {
410    Self {
411      asset,
412      new_content,
413      cached_ptb: OnceCell::new(),
414      package: client.package_id(),
415    }
416  }
417
418  async fn make_ptb(&self, client: &impl CoreClientReadOnly) -> Result<ProgrammableTransaction, Error> {
419    let tx_bcs = move_calls::asset::update(self.asset.object_ref(client).await?, &self.new_content, self.package)?;
420
421    Ok(bcs::from_bytes(&tx_bcs)?)
422  }
423}
424
425#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))]
426#[cfg_attr(feature = "send-sync", async_trait)]
427impl<T> Transaction for UpdateContent<'_, T>
428where
429  T: MoveType + Send + Sync,
430{
431  type Output = ();
432  type Error = Error;
433
434  async fn build_programmable_transaction<C>(&self, client: &C) -> Result<ProgrammableTransaction, Self::Error>
435  where
436    C: CoreClientReadOnly + OptionalSync,
437  {
438    self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned()
439  }
440  async fn apply<C>(self, effects: &mut IotaTransactionBlockEffects, _client: &C) -> Result<Self::Output, Self::Error>
441  where
442    C: CoreClientReadOnly + OptionalSync,
443  {
444    if let IotaExecutionStatus::Failure { error } = effects.status() {
445      return Err(Error::TransactionUnexpectedResponse(error.clone()));
446    }
447
448    if let Some(asset_pos) = effects
449      .mutated()
450      .iter()
451      .enumerate()
452      .find(|(_, obj)| obj.object_id() == self.asset.id())
453      .map(|(i, _)| i)
454    {
455      effects.mutated_mut().swap_remove(asset_pos);
456      self.asset.inner = self.new_content;
457    }
458
459    Ok(())
460  }
461}
462
463/// A [`Transaction`] that deletes an [`AuthenticatedAsset`].
464#[derive(Debug)]
465pub struct DeleteAsset<T> {
466  asset: AuthenticatedAsset<T>,
467  cached_ptb: OnceCell<ProgrammableTransaction>,
468  package: ObjectID,
469}
470
471impl<T: MoveType + Send + Sync> DeleteAsset<T> {
472  /// Returns a [Transaction] to delete `asset`.
473  pub fn new(asset: AuthenticatedAsset<T>, client: &IdentityClientReadOnly) -> Self {
474    Self {
475      asset,
476      cached_ptb: OnceCell::new(),
477      package: client.package_id(),
478    }
479  }
480
481  async fn make_ptb(&self, client: &impl CoreClientReadOnly) -> Result<ProgrammableTransaction, Error> {
482    let asset_ref = self.asset.object_ref(client).await?;
483    let tx_bcs = move_calls::asset::delete::<T>(asset_ref, self.package)?;
484
485    Ok(bcs::from_bytes(&tx_bcs)?)
486  }
487}
488
489#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))]
490#[cfg_attr(feature = "send-sync", async_trait)]
491impl<T> Transaction for DeleteAsset<T>
492where
493  T: MoveType + Send + Sync,
494{
495  type Output = ();
496  type Error = Error;
497
498  async fn build_programmable_transaction<C>(&self, client: &C) -> Result<ProgrammableTransaction, Self::Error>
499  where
500    C: CoreClientReadOnly + OptionalSync,
501  {
502    self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned()
503  }
504  async fn apply<C>(self, effects: &mut IotaTransactionBlockEffects, _client: &C) -> Result<Self::Output, Self::Error>
505  where
506    C: CoreClientReadOnly + OptionalSync,
507  {
508    if let IotaExecutionStatus::Failure { error } = effects.status() {
509      return Err(Error::TransactionUnexpectedResponse(error.clone()));
510    }
511
512    if let Some(asset_pos) = effects
513      .deleted()
514      .iter()
515      .enumerate()
516      .find_map(|(i, obj)| (obj.object_id == self.asset.id()).then_some(i))
517    {
518      effects.deleted_mut().swap_remove(asset_pos);
519      Ok(())
520    } else {
521      Err(Error::TransactionUnexpectedResponse(format!(
522        "cannot find asset {} in the list of delete objects",
523        self.asset.id()
524      )))
525    }
526  }
527}
528/// A [`Transaction`] that creates a new [`AuthenticatedAsset`].
529#[derive(Debug)]
530pub struct CreateAsset<T> {
531  builder: AuthenticatedAssetBuilder<T>,
532  cached_ptb: OnceCell<ProgrammableTransaction>,
533  package: ObjectID,
534}
535
536impl<T: MoveType> CreateAsset<T> {
537  /// Returns a [Transaction] to create the asset described by `builder`.
538  pub fn new(builder: AuthenticatedAssetBuilder<T>, client: &IdentityClientReadOnly) -> Self {
539    Self {
540      builder,
541      cached_ptb: OnceCell::new(),
542      package: client.package_id(),
543    }
544  }
545
546  async fn make_ptb(&self, _client: &impl CoreClientReadOnly) -> Result<ProgrammableTransaction, Error> {
547    let AuthenticatedAssetBuilder {
548      ref inner,
549      mutable,
550      transferable,
551      deletable,
552    } = self.builder;
553    let pt_bcs = move_calls::asset::new_asset(inner, mutable, transferable, deletable, self.package)?;
554    Ok(bcs::from_bytes(&pt_bcs)?)
555  }
556}
557
558#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))]
559#[cfg_attr(feature = "send-sync", async_trait)]
560impl<T> Transaction for CreateAsset<T>
561where
562  T: MoveType + DeserializeOwned + PartialEq + Send + Sync,
563{
564  type Output = AuthenticatedAsset<T>;
565  type Error = Error;
566
567  async fn build_programmable_transaction<C>(&self, client: &C) -> Result<ProgrammableTransaction, Self::Error>
568  where
569    C: CoreClientReadOnly + OptionalSync,
570  {
571    self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned()
572  }
573
574  async fn apply<C>(self, effects: &mut IotaTransactionBlockEffects, client: &C) -> Result<Self::Output, Self::Error>
575  where
576    C: CoreClientReadOnly + OptionalSync,
577  {
578    if let IotaExecutionStatus::Failure { error } = effects.status() {
579      return Err(Error::TransactionUnexpectedResponse(error.clone()));
580    }
581
582    let created_objects = effects
583      .created()
584      .iter()
585      .enumerate()
586      .filter(|(_, obj)| obj.owner.is_address_owned())
587      .map(|(i, obj)| (i, obj.object_id()));
588
589    let is_target_asset = |asset: &AuthenticatedAsset<T>| -> bool {
590      asset.inner == self.builder.inner
591        && asset.transferable == self.builder.transferable
592        && asset.mutable == self.builder.mutable
593        && asset.deletable == self.builder.deletable
594    };
595
596    let mut target_asset_pos = None;
597    let mut target_asset = None;
598    for (i, obj_id) in created_objects {
599      match AuthenticatedAsset::get_by_id(obj_id, client).await {
600        Ok(asset) if is_target_asset(&asset) => {
601          target_asset_pos = Some(i);
602          target_asset = Some(asset);
603          break;
604        }
605        _ => continue,
606      }
607    }
608
609    let (Some(pos), Some(asset)) = (target_asset_pos, target_asset) else {
610      return Err(Error::TransactionUnexpectedResponse(
611        "failed to find the asset created by this operation in transaction's effects".to_owned(),
612      ));
613    };
614
615    effects.created_mut().swap_remove(pos);
616
617    Ok(asset)
618  }
619}
620
621/// A [`Transaction`] that proposes the transfer of an [`AuthenticatedAsset`].
622#[derive(Debug)]
623pub struct TransferAsset<T> {
624  asset: AuthenticatedAsset<T>,
625  recipient: IotaAddress,
626  cached_ptb: OnceCell<ProgrammableTransaction>,
627  package: ObjectID,
628}
629
630impl<T: MoveType + Send + Sync> TransferAsset<T> {
631  /// Returns a [Transaction] to transfer `asset` to `recipient`.
632  pub fn new(asset: AuthenticatedAsset<T>, recipient: IotaAddress, client: &IdentityClientReadOnly) -> Self {
633    Self {
634      asset,
635      recipient,
636      cached_ptb: OnceCell::new(),
637      package: client.package_id(),
638    }
639  }
640
641  async fn make_ptb(&self, client: &impl CoreClientReadOnly) -> Result<ProgrammableTransaction, Error> {
642    let bcs = move_calls::asset::transfer::<T>(self.asset.object_ref(client).await?, self.recipient, self.package)?;
643
644    Ok(bcs::from_bytes(&bcs)?)
645  }
646}
647
648#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))]
649#[cfg_attr(feature = "send-sync", async_trait)]
650impl<T> Transaction for TransferAsset<T>
651where
652  T: MoveType + Send + Sync,
653{
654  type Output = TransferProposal;
655  type Error = Error;
656
657  async fn build_programmable_transaction<C>(&self, client: &C) -> Result<ProgrammableTransaction, Self::Error>
658  where
659    C: CoreClientReadOnly + OptionalSync,
660  {
661    self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned()
662  }
663  async fn apply<C>(self, effects: &mut IotaTransactionBlockEffects, client: &C) -> Result<Self::Output, Self::Error>
664  where
665    C: CoreClientReadOnly + OptionalSync,
666  {
667    if let IotaExecutionStatus::Failure { error } = effects.status() {
668      return Err(Error::TransactionUnexpectedResponse(error.clone()));
669    }
670
671    let created_objects = effects
672      .created()
673      .iter()
674      .enumerate()
675      .filter(|(_, obj)| obj.owner.is_shared())
676      .map(|(i, obj)| (i, obj.object_id()));
677
678    let is_target_proposal = |proposal: &TransferProposal| -> bool {
679      proposal.asset_id == self.asset.id() && proposal.recipient_address == self.recipient
680    };
681
682    let mut target_proposal_pos = None;
683    let mut target_proposal = None;
684    for (i, obj_id) in created_objects {
685      match TransferProposal::get_by_id(obj_id, client).await {
686        Ok(proposal) if is_target_proposal(&proposal) => {
687          target_proposal_pos = Some(i);
688          target_proposal = Some(proposal);
689          break;
690        }
691        _ => continue,
692      }
693    }
694
695    let (Some(pos), Some(proposal)) = (target_proposal_pos, target_proposal) else {
696      return Err(Error::TransactionUnexpectedResponse(
697        "failed to find the TransferProposal created by this operation in transaction's effects".to_owned(),
698      ));
699    };
700
701    effects.created_mut().swap_remove(pos);
702
703    Ok(proposal)
704  }
705}
706
707/// A [`Transaction`] that accepts the transfer of an [`AuthenticatedAsset`].
708#[derive(Debug)]
709pub struct AcceptTransfer {
710  proposal: TransferProposal,
711  cached_ptb: OnceCell<ProgrammableTransaction>,
712  package: ObjectID,
713}
714
715impl AcceptTransfer {
716  /// Returns a [Transaction] to accept `proposal`.
717  pub fn new(proposal: TransferProposal, client: &IdentityClientReadOnly) -> Self {
718    Self {
719      proposal,
720      cached_ptb: OnceCell::new(),
721      package: client.package_id(),
722    }
723  }
724
725  async fn make_ptb<C>(&self, client: &C) -> Result<ProgrammableTransaction, Error>
726  where
727    C: CoreClientReadOnly + OptionalSync,
728  {
729    if self.proposal.done {
730      return Err(Error::TransactionBuildingFailed(
731        "the transfer has already been concluded".to_owned(),
732      ));
733    }
734
735    let cap = self.proposal.get_cap("RecipientCap", client).await?;
736    let (asset_ref, param_type) = self
737      .proposal
738      .asset_metadata(client)
739      .await
740      .map_err(|e| Error::ObjectLookup(e.to_string()))?;
741    let initial_shared_version = self
742      .proposal
743      .initial_shared_version(client)
744      .await
745      .map_err(|e| Error::ObjectLookup(e.to_string()))?;
746    let bcs = move_calls::asset::accept_proposal(
747      (self.proposal.id(), initial_shared_version),
748      cap,
749      asset_ref,
750      param_type,
751      self.package,
752    )?;
753
754    Ok(bcs::from_bytes(&bcs)?)
755  }
756}
757
758#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))]
759#[cfg_attr(feature = "send-sync", async_trait)]
760impl Transaction for AcceptTransfer {
761  type Output = ();
762  type Error = Error;
763
764  async fn build_programmable_transaction<C>(&self, client: &C) -> Result<ProgrammableTransaction, Self::Error>
765  where
766    C: CoreClientReadOnly + OptionalSync,
767  {
768    self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned()
769  }
770
771  async fn apply<C>(self, effects: &mut IotaTransactionBlockEffects, _client: &C) -> Result<Self::Output, Self::Error>
772  where
773    C: CoreClientReadOnly + OptionalSync,
774  {
775    if let IotaExecutionStatus::Failure { error } = effects.status() {
776      return Err(Error::TransactionUnexpectedResponse(error.clone()));
777    }
778
779    if let Some(i) = effects
780      .deleted()
781      .iter()
782      .enumerate()
783      // The tx was successful if the recipient cap was consumed.
784      .find_map(|(i, obj)| (obj.object_id == self.proposal.recipient_cap_id).then_some(i))
785    {
786      effects.deleted_mut().swap_remove(i);
787      Ok(())
788    } else {
789      Err(Error::TransactionUnexpectedResponse(format!(
790        "transfer of asset {} through proposal {} wasn't successful",
791        self.proposal.asset_id,
792        self.proposal.id.object_id()
793      )))
794    }
795  }
796}
797
798/// A [`Transaction`] that concludes the transfer of an [`AuthenticatedAsset`].
799#[derive(Debug)]
800pub struct ConcludeTransfer {
801  proposal: TransferProposal,
802  cached_ptb: OnceCell<ProgrammableTransaction>,
803  package: ObjectID,
804}
805
806impl ConcludeTransfer {
807  /// Returns a [Transaction] to consume `proposal`.
808  pub fn new(proposal: TransferProposal, client: &IdentityClientReadOnly) -> Self {
809    Self {
810      proposal,
811      cached_ptb: OnceCell::new(),
812      package: client.package_id(),
813    }
814  }
815
816  async fn make_ptb<C>(&self, client: &C) -> Result<ProgrammableTransaction, Error>
817  where
818    C: CoreClientReadOnly + OptionalSync,
819  {
820    let cap = self.proposal.get_cap("SenderCap", client).await?;
821    let (asset_ref, param_type) = self
822      .proposal
823      .asset_metadata(client)
824      .await
825      .map_err(|e| Error::ObjectLookup(e.to_string()))?;
826    let initial_shared_version = self
827      .proposal
828      .initial_shared_version(client)
829      .await
830      .map_err(|e| Error::ObjectLookup(e.to_string()))?;
831
832    let tx_bcs = move_calls::asset::conclude_or_cancel(
833      (self.proposal.id(), initial_shared_version),
834      cap,
835      asset_ref,
836      param_type,
837      self.package,
838    )?;
839
840    Ok(bcs::from_bytes(&tx_bcs)?)
841  }
842}
843
844#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))]
845#[cfg_attr(feature = "send-sync", async_trait)]
846impl Transaction for ConcludeTransfer {
847  type Output = ();
848  type Error = Error;
849
850  async fn build_programmable_transaction<C>(&self, client: &C) -> Result<ProgrammableTransaction, Self::Error>
851  where
852    C: CoreClientReadOnly + OptionalSync,
853  {
854    self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned()
855  }
856
857  async fn apply<C>(self, effects: &mut IotaTransactionBlockEffects, _client: &C) -> Result<Self::Output, Error>
858  where
859    C: CoreClientReadOnly + OptionalSync,
860  {
861    if let IotaExecutionStatus::Failure { error } = effects.status() {
862      return Err(Error::TransactionUnexpectedResponse(error.clone()));
863    }
864
865    let mut idx_to_remove = effects
866      .deleted()
867      .iter()
868      .enumerate()
869      .filter_map(|(i, obj)| {
870        (obj.object_id == *self.proposal.id.object_id() || obj.object_id == self.proposal.sender_cap_id).then_some(i)
871      })
872      .collect::<Vec<_>>();
873
874    if idx_to_remove.len() < 2 {
875      return Err(Error::TransactionUnexpectedResponse(format!(
876        "conclusion or canceling of proposal {} wasn't successful",
877        self.proposal.id.object_id()
878      )));
879    }
880
881    // Ordering the list of indexis to remove is important to avoid invalidating the positions
882    // of the objects in the list. If we remove them from the higher index to the lower this
883    // shouldn't happen.
884    idx_to_remove.sort_unstable();
885    for i in idx_to_remove.into_iter().rev() {
886      effects.deleted_mut().swap_remove(i);
887    }
888
889    Ok(())
890  }
891}