Skip to main content

audit_trails/core/records/
transactions.rs

1// Copyright 2020-2026 IOTA Stiftung
2// SPDX-License-Identifier: Apache-2.0
3
4//! Transaction payloads for record writes and deletions.
5//!
6//! These types cache the generated programmable transaction, delegate PTB construction to
7//! [`super::operations::RecordsOps`], and decode record 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::RecordsOps;
19use crate::core::types::{Data, Event, RecordAdded, RecordDeleted};
20use crate::error::Error;
21
22// ===== AddRecord =====
23
24/// Transaction that appends a record to a trail.
25///
26/// Requires the `AddRecord` permission. Tagged writes additionally require the tag to exist in the trail
27/// registry and a capability whose role explicitly allows that tag; otherwise the Move package aborts with
28/// `ERecordTagNotDefined` or `ERecordTagNotAllowed`. The package also aborts with `ETrailWriteLocked` while
29/// the configured `write_lock` is active. On success the new record is stored at the trail's current
30/// monotonic sequence number (which never decrements, even after deletions) and a `RecordAdded` event is
31/// emitted.
32#[derive(Debug, Clone)]
33pub struct AddRecord {
34    /// Trail object ID that will receive the record.
35    pub trail_id: ObjectID,
36    /// Address authorizing the write.
37    pub owner: IotaAddress,
38    /// Record payload to append.
39    pub data: Data,
40    /// Optional application-defined metadata.
41    pub metadata: Option<String>,
42    /// Optional trail-owned tag to attach to the record.
43    pub tag: Option<String>,
44    /// Explicit capability to use instead of auto-selecting one from the owner's wallet.
45    pub selected_capability_id: Option<ObjectID>,
46    cached_ptb: OnceCell<ProgrammableTransaction>,
47}
48
49impl AddRecord {
50    /// Creates an `AddRecord` transaction builder payload.
51    pub fn new(
52        trail_id: ObjectID,
53        owner: IotaAddress,
54        data: Data,
55        metadata: Option<String>,
56        tag: Option<String>,
57        selected_capability_id: Option<ObjectID>,
58    ) -> Self {
59        Self {
60            trail_id,
61            owner,
62            data,
63            metadata,
64            tag,
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        RecordsOps::add_record(
75            client,
76            self.trail_id,
77            self.owner,
78            self.data.clone(),
79            self.metadata.clone(),
80            self.tag.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 AddRecord {
90    type Error = Error;
91    type Output = RecordAdded;
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| serde_json::from_value::<Event<RecordAdded>>(data.parsed_json.clone()).ok())
113            .ok_or_else(|| Error::UnexpectedApiResponse("RecordAdded event not found".to_string()))?;
114
115        Ok(event.data)
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!()
123    }
124}
125
126// ===== DeleteRecord =====
127
128/// Transaction that deletes a single record.
129///
130/// Requires the `DeleteRecord` permission. The Move package aborts with `ERecordNotFound` when no record
131/// exists at `sequence_number` and with `ERecordLocked` while the configured delete-record window still
132/// protects the record. Tag-aware authorization additionally applies: if the record carries a tag, the
133/// supplied capability's role must allow that tag.
134///
135/// On success a `RecordDeleted` event is emitted.
136#[derive(Debug, Clone)]
137pub struct DeleteRecord {
138    /// Trail object ID containing the record.
139    pub trail_id: ObjectID,
140    /// Address authorizing the deletion.
141    pub owner: IotaAddress,
142    /// Sequence number of the record to delete.
143    pub sequence_number: u64,
144    /// Explicit capability to use instead of auto-selecting one from the owner's wallet.
145    pub selected_capability_id: Option<ObjectID>,
146    cached_ptb: OnceCell<ProgrammableTransaction>,
147}
148
149impl DeleteRecord {
150    /// Creates a `DeleteRecord` transaction builder payload.
151    pub fn new(
152        trail_id: ObjectID,
153        owner: IotaAddress,
154        sequence_number: u64,
155        selected_capability_id: Option<ObjectID>,
156    ) -> Self {
157        Self {
158            trail_id,
159            owner,
160            sequence_number,
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        RecordsOps::delete_record(
171            client,
172            self.trail_id,
173            self.owner,
174            self.sequence_number,
175            self.selected_capability_id,
176        )
177        .await
178    }
179}
180
181#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))]
182#[cfg_attr(feature = "send-sync", async_trait)]
183impl Transaction for DeleteRecord {
184    type Error = Error;
185    type Output = RecordDeleted;
186
187    async fn build_programmable_transaction<C>(&self, client: &C) -> Result<ProgrammableTransaction, Self::Error>
188    where
189        C: CoreClientReadOnly + OptionalSync,
190    {
191        self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned()
192    }
193
194    async fn apply_with_events<C>(
195        mut self,
196        _: &mut IotaTransactionBlockEffects,
197        events: &mut IotaTransactionBlockEvents,
198        _: &C,
199    ) -> Result<Self::Output, Self::Error>
200    where
201        C: CoreClientReadOnly + OptionalSync,
202    {
203        let event = events
204            .data
205            .iter()
206            .find_map(|data| serde_json::from_value::<Event<RecordDeleted>>(data.parsed_json.clone()).ok())
207            .ok_or_else(|| Error::UnexpectedApiResponse("RecordDeleted event not found".to_string()))?;
208
209        Ok(event.data)
210    }
211
212    async fn apply<C>(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result<Self::Output, Self::Error>
213    where
214        C: CoreClientReadOnly + OptionalSync,
215    {
216        unreachable!()
217    }
218}
219
220// ===== DeleteRecordsBatch =====
221
222/// Transaction that deletes multiple records in a batch operation.
223///
224/// Requires the `DeleteAllRecords` permission. The Move entry point walks the trail from the front,
225/// silently skips records still inside the delete-record window or outside the capability's allowed tag set,
226/// and deletes up to `limit` eligible records in trail order.
227///
228/// On success a `RecordDeleted` event is emitted per deletion.
229///
230/// `limit` caps the number of records actually deleted, not the number of records inspected. Ineligible
231/// records at the front of the trail are silently walked past without counting toward `limit`, so more
232/// than `limit` records may be visited before `limit` deletions accumulate.
233///
234/// Lock state — both count-based
235/// and time-based — is evaluated against the trail snapshot and clock timestamp captured at the start of the
236/// call, so the deletable set is stable for the batch's duration. The Rust implementation mirrors the Move
237/// output by collecting the matching `RecordDeleted` events in deletion order; the returned vector may be
238/// shorter than `limit` (or empty) and that is not an error.
239#[derive(Debug, Clone)]
240pub struct DeleteRecordsBatch {
241    /// Trail object ID containing the records.
242    pub trail_id: ObjectID,
243    /// Address authorizing the deletion.
244    pub owner: IotaAddress,
245    /// Maximum number of records to delete in this batch.
246    pub limit: u64,
247    /// Explicit capability to use instead of auto-selecting one from the owner's wallet.
248    pub selected_capability_id: Option<ObjectID>,
249    cached_ptb: OnceCell<ProgrammableTransaction>,
250}
251
252impl DeleteRecordsBatch {
253    /// Creates a `DeleteRecordsBatch` transaction builder payload.
254    pub fn new(trail_id: ObjectID, owner: IotaAddress, limit: u64, selected_capability_id: Option<ObjectID>) -> Self {
255        Self {
256            trail_id,
257            owner,
258            limit,
259            selected_capability_id,
260            cached_ptb: OnceCell::new(),
261        }
262    }
263
264    async fn make_ptb<C>(&self, client: &C) -> Result<ProgrammableTransaction, Error>
265    where
266        C: CoreClientReadOnly + OptionalSync,
267    {
268        RecordsOps::delete_records_batch(
269            client,
270            self.trail_id,
271            self.owner,
272            self.limit,
273            self.selected_capability_id,
274        )
275        .await
276    }
277}
278
279#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))]
280#[cfg_attr(feature = "send-sync", async_trait)]
281impl Transaction for DeleteRecordsBatch {
282    type Error = Error;
283    type Output = Vec<u64>;
284
285    async fn build_programmable_transaction<C>(&self, client: &C) -> Result<ProgrammableTransaction, Self::Error>
286    where
287        C: CoreClientReadOnly + OptionalSync,
288    {
289        self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned()
290    }
291
292    async fn apply_with_events<C>(
293        self,
294        _: &mut IotaTransactionBlockEffects,
295        events: &mut IotaTransactionBlockEvents,
296        _: &C,
297    ) -> Result<Self::Output, Self::Error>
298    where
299        C: CoreClientReadOnly + OptionalSync,
300    {
301        let deleted = events
302            .data
303            .iter()
304            .filter_map(|data| serde_json::from_value::<Event<RecordDeleted>>(data.parsed_json.clone()).ok())
305            .map(|event| event.data.sequence_number)
306            .collect();
307
308        Ok(deleted)
309    }
310
311    async fn apply<C>(self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result<Self::Output, Self::Error>
312    where
313        C: CoreClientReadOnly + OptionalSync,
314    {
315        unreachable!()
316    }
317}