Skip to main content

audit_trails/core/access/
transactions.rs

1// Copyright 2020-2026 IOTA Stiftung
2// SPDX-License-Identifier: Apache-2.0
3
4//! Transaction payloads for audit-trail role and capability administration.
5//!
6//! These types cache the generated programmable transaction, delegate PTB construction to
7//! [`super::operations::AccessOps`], and decode the matching Move events into typed Rust outputs.
8
9use async_trait::async_trait;
10use iota_interaction::OptionalSync;
11use iota_interaction::rpc_types::{IotaTransactionBlockEffects, IotaTransactionBlockEvents};
12use iota_interaction::types::base_types::{IotaAddress, ObjectID};
13use iota_interaction::types::transaction::ProgrammableTransaction;
14use product_common::core_client::CoreClientReadOnly;
15use product_common::transaction::transaction_builder::Transaction;
16use tokio::sync::OnceCell;
17
18use super::operations::AccessOps;
19use crate::core::types::{
20    CapabilityDestroyed, CapabilityIssueOptions, CapabilityIssued, CapabilityRevoked, Event, PermissionSet,
21    RawRoleCreated, RawRoleDeleted, RawRoleUpdated, RevokedCapabilitiesCleanedUp, RoleCreated, RoleDeleted, RoleTags,
22    RoleUpdated,
23};
24use crate::error::Error;
25
26// ===== CreateRole =====
27
28/// Transaction that creates a role on a trail.
29///
30/// Requires an authorization capability with `AddRoles`. Any [`RoleTags`] supplied as role data must
31/// already be present in the trail's tag registry; otherwise the Move package aborts with
32/// `ERecordTagNotDefined`. Each tag referenced by the new role bumps that tag's usage counter, which
33/// then prevents the tag from being removed from the registry.
34///
35/// On success a `RoleCreated` event is emitted.
36#[derive(Debug, Clone)]
37pub struct CreateRole {
38    trail_id: ObjectID,
39    owner: IotaAddress,
40    name: String,
41    permissions: PermissionSet,
42    role_tags: Option<RoleTags>,
43    selected_capability_id: Option<ObjectID>,
44    cached_ptb: OnceCell<ProgrammableTransaction>,
45}
46
47impl CreateRole {
48    /// Creates a `CreateRole` transaction builder payload.
49    ///
50    /// `role_tags`, when present, are serialized as Move `record_tags::RoleTags` role data.
51    pub fn new(
52        trail_id: ObjectID,
53        owner: IotaAddress,
54        name: String,
55        permissions: PermissionSet,
56        role_tags: Option<RoleTags>,
57        selected_capability_id: Option<ObjectID>,
58    ) -> Self {
59        Self {
60            trail_id,
61            owner,
62            name,
63            permissions,
64            role_tags,
65            selected_capability_id,
66            cached_ptb: OnceCell::new(),
67        }
68    }
69
70    async fn make_ptb<C>(&self, client: &C) -> Result<ProgrammableTransaction, Error>
71    where
72        C: CoreClientReadOnly + OptionalSync,
73    {
74        AccessOps::create_role(
75            client,
76            self.trail_id,
77            self.owner,
78            self.name.clone(),
79            self.permissions.clone(),
80            self.role_tags.clone(),
81            self.selected_capability_id,
82        )
83        .await
84    }
85}
86
87#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))]
88#[cfg_attr(feature = "send-sync", async_trait)]
89impl Transaction for CreateRole {
90    type Error = Error;
91    type Output = RoleCreated;
92
93    async fn build_programmable_transaction<C>(&self, client: &C) -> Result<ProgrammableTransaction, Self::Error>
94    where
95        C: CoreClientReadOnly + OptionalSync,
96    {
97        self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned()
98    }
99
100    async fn apply_with_events<C>(
101        mut self,
102        _: &mut IotaTransactionBlockEffects,
103        events: &mut IotaTransactionBlockEvents,
104        _: &C,
105    ) -> Result<Self::Output, Self::Error>
106    where
107        C: CoreClientReadOnly + OptionalSync,
108    {
109        let event = events
110            .data
111            .iter()
112            .find_map(|data| bcs::from_bytes::<RawRoleCreated>(data.bcs.bytes()).ok().map(Into::into))
113            .ok_or_else(|| Error::UnexpectedApiResponse("RoleCreated event not found".to_string()))?;
114
115        Ok(event)
116    }
117
118    async fn apply<C>(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result<Self::Output, Self::Error>
119    where
120        C: CoreClientReadOnly + OptionalSync,
121    {
122        unreachable!("RoleCreated output requires transaction events")
123    }
124}
125
126/// Transaction that updates an existing role.
127///
128/// Requires the `UpdateRoles` permission. Updates both the permission set and the optional role-tag
129/// data stored for the role. Any newly supplied [`RoleTags`] must already be in the trail's tag
130/// registry, otherwise the Move package aborts with `ERecordTagNotDefined`. Tag usage counters are
131/// adjusted to reflect the difference between the old and new role-tag sets.
132///
133/// On success a `RoleUpdated` event is emitted.
134#[derive(Debug, Clone)]
135pub struct UpdateRole {
136    trail_id: ObjectID,
137    owner: IotaAddress,
138    name: String,
139    permissions: PermissionSet,
140    role_tags: Option<RoleTags>,
141    selected_capability_id: Option<ObjectID>,
142    cached_ptb: OnceCell<ProgrammableTransaction>,
143}
144
145impl UpdateRole {
146    /// Creates an `UpdateRole` transaction builder payload.
147    pub fn new(
148        trail_id: ObjectID,
149        owner: IotaAddress,
150        name: String,
151        permissions: PermissionSet,
152        role_tags: Option<RoleTags>,
153        selected_capability_id: Option<ObjectID>,
154    ) -> Self {
155        Self {
156            trail_id,
157            owner,
158            name,
159            permissions,
160            role_tags,
161            selected_capability_id,
162            cached_ptb: OnceCell::new(),
163        }
164    }
165
166    async fn make_ptb<C>(&self, client: &C) -> Result<ProgrammableTransaction, Error>
167    where
168        C: CoreClientReadOnly + OptionalSync,
169    {
170        AccessOps::update_role(
171            client,
172            self.trail_id,
173            self.owner,
174            self.name.clone(),
175            self.permissions.clone(),
176            self.role_tags.clone(),
177            self.selected_capability_id,
178        )
179        .await
180    }
181}
182
183#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))]
184#[cfg_attr(feature = "send-sync", async_trait)]
185impl Transaction for UpdateRole {
186    type Error = Error;
187    type Output = RoleUpdated;
188
189    async fn build_programmable_transaction<C>(&self, client: &C) -> Result<ProgrammableTransaction, Self::Error>
190    where
191        C: CoreClientReadOnly + OptionalSync,
192    {
193        self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned()
194    }
195
196    async fn apply_with_events<C>(
197        mut self,
198        _: &mut IotaTransactionBlockEffects,
199        events: &mut IotaTransactionBlockEvents,
200        _: &C,
201    ) -> Result<Self::Output, Self::Error>
202    where
203        C: CoreClientReadOnly + OptionalSync,
204    {
205        let event = events
206            .data
207            .iter()
208            .find_map(|data| bcs::from_bytes::<RawRoleUpdated>(data.bcs.bytes()).ok().map(Into::into))
209            .ok_or_else(|| Error::UnexpectedApiResponse("RoleUpdated event not found".to_string()))?;
210
211        Ok(event)
212    }
213
214    async fn apply<C>(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result<Self::Output, Self::Error>
215    where
216        C: CoreClientReadOnly + OptionalSync,
217    {
218        unreachable!()
219    }
220}
221
222/// Transaction that deletes a role.
223///
224/// Requires the `DeleteRoles` permission. The reserved initial-admin role (`"Admin"`) cannot be
225/// deleted, even by a holder of `DeleteRoles`. Removing a role decrements the usage counters of all
226/// tags it referenced through its [`RoleTags`].
227///
228/// On success a `RoleDeleted` event is emitted.
229#[derive(Debug, Clone)]
230pub struct DeleteRole {
231    trail_id: ObjectID,
232    owner: IotaAddress,
233    name: String,
234    selected_capability_id: Option<ObjectID>,
235    cached_ptb: OnceCell<ProgrammableTransaction>,
236}
237
238impl DeleteRole {
239    /// Creates a `DeleteRole` transaction builder payload.
240    pub fn new(trail_id: ObjectID, owner: IotaAddress, name: String, selected_capability_id: Option<ObjectID>) -> Self {
241        Self {
242            trail_id,
243            owner,
244            name,
245            selected_capability_id,
246            cached_ptb: OnceCell::new(),
247        }
248    }
249
250    async fn make_ptb<C>(&self, client: &C) -> Result<ProgrammableTransaction, Error>
251    where
252        C: CoreClientReadOnly + OptionalSync,
253    {
254        AccessOps::delete_role(
255            client,
256            self.trail_id,
257            self.owner,
258            self.name.clone(),
259            self.selected_capability_id,
260        )
261        .await
262    }
263}
264
265#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))]
266#[cfg_attr(feature = "send-sync", async_trait)]
267impl Transaction for DeleteRole {
268    type Error = Error;
269    type Output = RoleDeleted;
270
271    async fn build_programmable_transaction<C>(&self, client: &C) -> Result<ProgrammableTransaction, Self::Error>
272    where
273        C: CoreClientReadOnly + OptionalSync,
274    {
275        self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned()
276    }
277
278    async fn apply_with_events<C>(
279        mut self,
280        _: &mut IotaTransactionBlockEffects,
281        events: &mut IotaTransactionBlockEvents,
282        _: &C,
283    ) -> Result<Self::Output, Self::Error>
284    where
285        C: CoreClientReadOnly + OptionalSync,
286    {
287        let event = events
288            .data
289            .iter()
290            .find_map(|data| bcs::from_bytes::<RawRoleDeleted>(data.bcs.bytes()).ok().map(Into::into))
291            .ok_or_else(|| Error::UnexpectedApiResponse("RoleDeleted event not found".to_string()))?;
292
293        Ok(event)
294    }
295
296    async fn apply<C>(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result<Self::Output, Self::Error>
297    where
298        C: CoreClientReadOnly + OptionalSync,
299    {
300        unreachable!()
301    }
302}
303
304/// Transaction that issues a capability for a role.
305///
306/// Requires the `AddCapabilities` permission. Mints a new capability object for `role` against
307/// `trail_id` and transfers it to the address in [`CapabilityIssueOptions::issued_to`] (or the caller
308/// if absent). Optional `valid_from_ms` / `valid_until_ms` restrictions are copied into the capability
309/// object and later enforced on-chain when the capability is used. A `CapabilityIssued` event is
310/// emitted on success.
311#[derive(Debug, Clone)]
312pub struct IssueCapability {
313    trail_id: ObjectID,
314    owner: IotaAddress,
315    role: String,
316    options: CapabilityIssueOptions,
317    selected_capability_id: Option<ObjectID>,
318    cached_ptb: OnceCell<ProgrammableTransaction>,
319}
320
321impl IssueCapability {
322    /// Creates an `IssueCapability` transaction builder payload.
323    pub fn new(
324        trail_id: ObjectID,
325        owner: IotaAddress,
326        role: String,
327        options: CapabilityIssueOptions,
328        selected_capability_id: Option<ObjectID>,
329    ) -> Self {
330        Self {
331            trail_id,
332            owner,
333            role,
334            options,
335            selected_capability_id,
336            cached_ptb: OnceCell::new(),
337        }
338    }
339
340    async fn make_ptb<C>(&self, client: &C) -> Result<ProgrammableTransaction, Error>
341    where
342        C: CoreClientReadOnly + OptionalSync,
343    {
344        AccessOps::issue_capability(
345            client,
346            self.trail_id,
347            self.owner,
348            self.role.clone(),
349            self.options.clone(),
350            self.selected_capability_id,
351        )
352        .await
353    }
354}
355
356#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))]
357#[cfg_attr(feature = "send-sync", async_trait)]
358impl Transaction for IssueCapability {
359    type Error = Error;
360    type Output = CapabilityIssued;
361
362    async fn build_programmable_transaction<C>(&self, client: &C) -> Result<ProgrammableTransaction, Self::Error>
363    where
364        C: CoreClientReadOnly + OptionalSync,
365    {
366        self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned()
367    }
368
369    async fn apply_with_events<C>(
370        mut self,
371        _: &mut IotaTransactionBlockEffects,
372        events: &mut IotaTransactionBlockEvents,
373        _: &C,
374    ) -> Result<Self::Output, Self::Error>
375    where
376        C: CoreClientReadOnly + OptionalSync,
377    {
378        let event = events
379            .data
380            .iter()
381            .find_map(|data| serde_json::from_value::<Event<CapabilityIssued>>(data.parsed_json.clone()).ok())
382            .ok_or_else(|| Error::UnexpectedApiResponse("CapabilityIssued event not found".to_string()))?;
383
384        Ok(event.data)
385    }
386
387    async fn apply<C>(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result<Self::Output, Self::Error>
388    where
389        C: CoreClientReadOnly + OptionalSync,
390    {
391        unreachable!()
392    }
393}
394
395/// Transaction that revokes a capability.
396///
397/// Requires the `RevokeCapabilities` permission. Revocation writes the capability ID into the trail's
398/// revoked-capability denylist. Supplying `capability_valid_until` preserves the capability's original
399/// expiry boundary so [`CleanupRevokedCapabilities`] can later prune the entry once that timestamp has
400/// elapsed; pass `None` (which becomes `0` on chain) to keep the entry permanently. A
401/// `CapabilityRevoked` event is emitted on success.
402#[derive(Debug, Clone)]
403pub struct RevokeCapability {
404    trail_id: ObjectID,
405    owner: IotaAddress,
406    capability_id: ObjectID,
407    capability_valid_until: Option<u64>,
408    selected_capability_id: Option<ObjectID>,
409    cached_ptb: OnceCell<ProgrammableTransaction>,
410}
411
412impl RevokeCapability {
413    /// Creates a `RevokeCapability` transaction builder payload.
414    pub fn new(
415        trail_id: ObjectID,
416        owner: IotaAddress,
417        capability_id: ObjectID,
418        capability_valid_until: Option<u64>,
419        selected_capability_id: Option<ObjectID>,
420    ) -> Self {
421        Self {
422            trail_id,
423            owner,
424            capability_id,
425            capability_valid_until,
426            selected_capability_id,
427            cached_ptb: OnceCell::new(),
428        }
429    }
430
431    async fn make_ptb<C>(&self, client: &C) -> Result<ProgrammableTransaction, Error>
432    where
433        C: CoreClientReadOnly + OptionalSync,
434    {
435        AccessOps::revoke_capability(
436            client,
437            self.trail_id,
438            self.owner,
439            self.capability_id,
440            self.capability_valid_until,
441            self.selected_capability_id,
442        )
443        .await
444    }
445}
446
447#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))]
448#[cfg_attr(feature = "send-sync", async_trait)]
449impl Transaction for RevokeCapability {
450    type Error = Error;
451    type Output = CapabilityRevoked;
452
453    async fn build_programmable_transaction<C>(&self, client: &C) -> Result<ProgrammableTransaction, Self::Error>
454    where
455        C: CoreClientReadOnly + OptionalSync,
456    {
457        self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned()
458    }
459
460    async fn apply_with_events<C>(
461        mut self,
462        _: &mut IotaTransactionBlockEffects,
463        events: &mut IotaTransactionBlockEvents,
464        _: &C,
465    ) -> Result<Self::Output, Self::Error>
466    where
467        C: CoreClientReadOnly + OptionalSync,
468    {
469        let event = events
470            .data
471            .iter()
472            .find_map(|data| serde_json::from_value::<Event<CapabilityRevoked>>(data.parsed_json.clone()).ok())
473            .ok_or_else(|| Error::UnexpectedApiResponse("CapabilityRevoked event not found".to_string()))?;
474
475        Ok(event.data)
476    }
477
478    async fn apply<C>(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result<Self::Output, Self::Error>
479    where
480        C: CoreClientReadOnly + OptionalSync,
481    {
482        unreachable!()
483    }
484}
485
486/// Transaction that destroys a capability object.
487///
488/// Requires the `RevokeCapabilities` permission and consumes the owned capability object. This path is
489/// for ordinary capabilities only — initial-admin capabilities must use [`DestroyInitialAdminCapability`]
490/// instead, since their IDs are tracked separately. A `CapabilityDestroyed` event is emitted on
491/// success.
492#[derive(Debug, Clone)]
493pub struct DestroyCapability {
494    trail_id: ObjectID,
495    owner: IotaAddress,
496    capability_id: ObjectID,
497    selected_capability_id: Option<ObjectID>,
498    cached_ptb: OnceCell<ProgrammableTransaction>,
499}
500
501impl DestroyCapability {
502    /// Creates a `DestroyCapability` transaction builder payload.
503    pub fn new(
504        trail_id: ObjectID,
505        owner: IotaAddress,
506        capability_id: ObjectID,
507        selected_capability_id: Option<ObjectID>,
508    ) -> Self {
509        Self {
510            trail_id,
511            owner,
512            capability_id,
513            selected_capability_id,
514            cached_ptb: OnceCell::new(),
515        }
516    }
517
518    async fn make_ptb<C>(&self, client: &C) -> Result<ProgrammableTransaction, Error>
519    where
520        C: CoreClientReadOnly + OptionalSync,
521    {
522        AccessOps::destroy_capability(
523            client,
524            self.trail_id,
525            self.owner,
526            self.capability_id,
527            self.selected_capability_id,
528        )
529        .await
530    }
531}
532
533#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))]
534#[cfg_attr(feature = "send-sync", async_trait)]
535impl Transaction for DestroyCapability {
536    type Error = Error;
537    type Output = CapabilityDestroyed;
538
539    async fn build_programmable_transaction<C>(&self, client: &C) -> Result<ProgrammableTransaction, Self::Error>
540    where
541        C: CoreClientReadOnly + OptionalSync,
542    {
543        self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned()
544    }
545
546    async fn apply_with_events<C>(
547        mut self,
548        _: &mut IotaTransactionBlockEffects,
549        events: &mut IotaTransactionBlockEvents,
550        _: &C,
551    ) -> Result<Self::Output, Self::Error>
552    where
553        C: CoreClientReadOnly + OptionalSync,
554    {
555        let event = events
556            .data
557            .iter()
558            .find_map(|data| serde_json::from_value::<Event<CapabilityDestroyed>>(data.parsed_json.clone()).ok())
559            .ok_or_else(|| Error::UnexpectedApiResponse("CapabilityDestroyed event not found".to_string()))?;
560
561        Ok(event.data)
562    }
563
564    async fn apply<C>(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result<Self::Output, Self::Error>
565    where
566        C: CoreClientReadOnly + OptionalSync,
567    {
568        unreachable!()
569    }
570}
571
572// ===== DestroyInitialAdminCapability =====
573
574/// Transaction that destroys an initial-admin capability without an auth capability.
575///
576/// Self-service: the holder passes their own initial-admin capability and consumes it; no additional
577/// authorization is required because the capability itself proves ownership. Initial-admin capability
578/// IDs are tracked separately and cannot be removed through the generic destroy path.
579///
580/// **Warning:** if every initial-admin capability is destroyed (and none was issued separately), the
581/// trail is permanently sealed with no admin access possible.
582///
583/// On success a `CapabilityDestroyed` event is emitted.
584#[derive(Debug, Clone)]
585pub struct DestroyInitialAdminCapability {
586    trail_id: ObjectID,
587    capability_id: ObjectID,
588    cached_ptb: OnceCell<ProgrammableTransaction>,
589}
590
591impl DestroyInitialAdminCapability {
592    /// Creates a `DestroyInitialAdminCapability` transaction builder payload.
593    pub fn new(trail_id: ObjectID, capability_id: ObjectID) -> Self {
594        Self {
595            trail_id,
596            capability_id,
597            cached_ptb: OnceCell::new(),
598        }
599    }
600
601    async fn make_ptb<C>(&self, client: &C) -> Result<ProgrammableTransaction, Error>
602    where
603        C: CoreClientReadOnly + OptionalSync,
604    {
605        AccessOps::destroy_initial_admin_capability(client, self.trail_id, self.capability_id).await
606    }
607}
608
609#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))]
610#[cfg_attr(feature = "send-sync", async_trait)]
611impl Transaction for DestroyInitialAdminCapability {
612    type Error = Error;
613    type Output = CapabilityDestroyed;
614
615    async fn build_programmable_transaction<C>(&self, client: &C) -> Result<ProgrammableTransaction, Self::Error>
616    where
617        C: CoreClientReadOnly + OptionalSync,
618    {
619        self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned()
620    }
621
622    async fn apply_with_events<C>(
623        mut self,
624        _: &mut IotaTransactionBlockEffects,
625        events: &mut IotaTransactionBlockEvents,
626        _: &C,
627    ) -> Result<Self::Output, Self::Error>
628    where
629        C: CoreClientReadOnly + OptionalSync,
630    {
631        let event = events
632            .data
633            .iter()
634            .find_map(|data| serde_json::from_value::<Event<CapabilityDestroyed>>(data.parsed_json.clone()).ok())
635            .ok_or_else(|| Error::UnexpectedApiResponse("CapabilityDestroyed event not found".to_string()))?;
636
637        Ok(event.data)
638    }
639
640    async fn apply<C>(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result<Self::Output, Self::Error>
641    where
642        C: CoreClientReadOnly + OptionalSync,
643    {
644        unreachable!()
645    }
646}
647
648// ===== RevokeInitialAdminCapability =====
649
650/// Transaction that revokes an initial-admin capability.
651///
652/// Requires the `RevokeCapabilities` permission. This is the dedicated revoke path for capability IDs
653/// recognized as active initial-admin capabilities; ordinary capabilities must use [`RevokeCapability`]
654/// instead. The same denylist semantics apply: pass the capability's `valid_until` to allow later
655/// cleanup once the original expiry elapses, or `None` to keep the denylist entry permanently.
656///
657/// **Warning:** revoking every initial-admin capability permanently seals the trail with no admin
658/// access possible.
659///
660/// On success a `CapabilityRevoked` event is emitted.
661#[derive(Debug, Clone)]
662pub struct RevokeInitialAdminCapability {
663    trail_id: ObjectID,
664    owner: IotaAddress,
665    capability_id: ObjectID,
666    capability_valid_until: Option<u64>,
667    selected_capability_id: Option<ObjectID>,
668    cached_ptb: OnceCell<ProgrammableTransaction>,
669}
670
671impl RevokeInitialAdminCapability {
672    /// Creates a `RevokeInitialAdminCapability` transaction builder payload.
673    pub fn new(
674        trail_id: ObjectID,
675        owner: IotaAddress,
676        capability_id: ObjectID,
677        capability_valid_until: Option<u64>,
678        selected_capability_id: Option<ObjectID>,
679    ) -> Self {
680        Self {
681            trail_id,
682            owner,
683            capability_id,
684            capability_valid_until,
685            selected_capability_id,
686            cached_ptb: OnceCell::new(),
687        }
688    }
689
690    async fn make_ptb<C>(&self, client: &C) -> Result<ProgrammableTransaction, Error>
691    where
692        C: CoreClientReadOnly + OptionalSync,
693    {
694        AccessOps::revoke_initial_admin_capability(
695            client,
696            self.trail_id,
697            self.owner,
698            self.capability_id,
699            self.capability_valid_until,
700            self.selected_capability_id,
701        )
702        .await
703    }
704}
705
706#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))]
707#[cfg_attr(feature = "send-sync", async_trait)]
708impl Transaction for RevokeInitialAdminCapability {
709    type Error = Error;
710    type Output = CapabilityRevoked;
711
712    async fn build_programmable_transaction<C>(&self, client: &C) -> Result<ProgrammableTransaction, Self::Error>
713    where
714        C: CoreClientReadOnly + OptionalSync,
715    {
716        self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned()
717    }
718
719    async fn apply_with_events<C>(
720        mut self,
721        _: &mut IotaTransactionBlockEffects,
722        events: &mut IotaTransactionBlockEvents,
723        _: &C,
724    ) -> Result<Self::Output, Self::Error>
725    where
726        C: CoreClientReadOnly + OptionalSync,
727    {
728        let event = events
729            .data
730            .iter()
731            .find_map(|data| serde_json::from_value::<Event<CapabilityRevoked>>(data.parsed_json.clone()).ok())
732            .ok_or_else(|| Error::UnexpectedApiResponse("CapabilityRevoked event not found".to_string()))?;
733
734        Ok(event.data)
735    }
736
737    async fn apply<C>(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result<Self::Output, Self::Error>
738    where
739        C: CoreClientReadOnly + OptionalSync,
740    {
741        unreachable!()
742    }
743}
744
745/// Transaction that cleans up expired revoked-capability entries.
746///
747/// Requires the `RevokeCapabilities` permission. Only prunes denylist entries whose stored
748/// `valid_until` is *non-zero* and *strictly less than* the current clock time; entries with
749/// `valid_until == 0` (capabilities revoked without a known expiry) remain on the denylist
750/// indefinitely. This does not revoke additional capabilities and does not destroy any objects.
751///
752/// On success a `RevokedCapabilitiesCleanedUp` event is emitted.
753///
754/// Returns the typed cleanup receipt with the trail ID, the number of entries removed, the address that
755/// triggered the cleanup, and the millisecond timestamp.
756#[derive(Debug, Clone)]
757pub struct CleanupRevokedCapabilities {
758    trail_id: ObjectID,
759    owner: IotaAddress,
760    selected_capability_id: Option<ObjectID>,
761    cached_ptb: OnceCell<ProgrammableTransaction>,
762}
763
764impl CleanupRevokedCapabilities {
765    /// Creates a `CleanupRevokedCapabilities` transaction builder payload.
766    pub fn new(trail_id: ObjectID, owner: IotaAddress, selected_capability_id: Option<ObjectID>) -> Self {
767        Self {
768            trail_id,
769            owner,
770            selected_capability_id,
771            cached_ptb: OnceCell::new(),
772        }
773    }
774
775    async fn make_ptb<C>(&self, client: &C) -> Result<ProgrammableTransaction, Error>
776    where
777        C: CoreClientReadOnly + OptionalSync,
778    {
779        AccessOps::cleanup_revoked_capabilities(client, self.trail_id, self.owner, self.selected_capability_id).await
780    }
781}
782
783#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))]
784#[cfg_attr(feature = "send-sync", async_trait)]
785impl Transaction for CleanupRevokedCapabilities {
786    type Error = Error;
787    type Output = RevokedCapabilitiesCleanedUp;
788
789    async fn build_programmable_transaction<C>(&self, client: &C) -> Result<ProgrammableTransaction, Self::Error>
790    where
791        C: CoreClientReadOnly + OptionalSync,
792    {
793        self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned()
794    }
795
796    async fn apply_with_events<C>(
797        self,
798        _: &mut IotaTransactionBlockEffects,
799        events: &mut IotaTransactionBlockEvents,
800        _: &C,
801    ) -> Result<Self::Output, Self::Error>
802    where
803        C: CoreClientReadOnly + OptionalSync,
804    {
805        let event = events
806            .data
807            .iter()
808            .find_map(|data| {
809                serde_json::from_value::<Event<RevokedCapabilitiesCleanedUp>>(data.parsed_json.clone()).ok()
810            })
811            .ok_or_else(|| Error::UnexpectedApiResponse("RevokedCapabilitiesCleanedUp event not found".to_string()))?;
812
813        Ok(event.data)
814    }
815
816    async fn apply<C>(self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result<Self::Output, Self::Error>
817    where
818        C: CoreClientReadOnly + OptionalSync,
819    {
820        unreachable!("RevokedCapabilitiesCleanedUp output requires transaction events")
821    }
822}