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_sdk_types::{Address, ObjectId, ProgrammableTransaction};
13use product_common::core_client::CoreClientReadOnly;
14use product_common::transaction::transaction_builder::Transaction;
15use tokio::sync::OnceCell;
16
17use super::operations::RecordsOps;
18use crate::core::internal::tx;
19use crate::core::types::{Data, Event, RecordAdded, RecordDeleted, RecordInput};
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: Address,
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: Address,
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            RecordInput::new(self.data.clone(), self.metadata.clone(), self.tag.clone()),
79            self.selected_capability_id,
80        )
81        .await
82    }
83}
84
85#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))]
86#[cfg_attr(feature = "send-sync", async_trait)]
87impl Transaction for AddRecord {
88    type Error = Error;
89    type Output = RecordAdded;
90
91    async fn build_programmable_transaction<C>(&self, client: &C) -> Result<ProgrammableTransaction, Self::Error>
92    where
93        C: CoreClientReadOnly + OptionalSync,
94    {
95        self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned()
96    }
97
98    async fn apply_with_events<C>(
99        mut self,
100        _: &mut IotaTransactionBlockEffects,
101        events: &mut IotaTransactionBlockEvents,
102        _: &C,
103    ) -> Result<Self::Output, Self::Error>
104    where
105        C: CoreClientReadOnly + OptionalSync,
106    {
107        let event = events
108            .data
109            .iter()
110            .find_map(|data| serde_json::from_value::<Event<RecordAdded>>(data.parsed_json.clone()).ok())
111            .ok_or_else(|| Error::UnexpectedApiResponse("RecordAdded event not found".to_string()))?;
112
113        Ok(event.data)
114    }
115
116    async fn apply<C>(self, effects: &mut IotaTransactionBlockEffects, client: &C) -> Result<Self::Output, Self::Error>
117    where
118        C: CoreClientReadOnly + OptionalSync,
119    {
120        tx::apply_with_events(self, effects, client).await
121    }
122}
123
124// ===== CorrectRecord =====
125
126/// Transaction that appends a correction record to a trail.
127///
128/// The original record remains immutable. The correction is appended at the trail's next sequence number with
129/// a correction tracker whose `replaces` set contains the corrected sequence number, and the corrected record
130/// receives a back-pointer to the new correction.
131///
132/// Requires the `CorrectRecord` permission. Tagged corrections require the correction tag to exist in the trail
133/// registry and the capability's role to allow both the replaced record's tag, when present, and the correction
134/// tag, when present. The Move call aborts when the trail package version is incompatible, the capability is
135/// invalid, the trail is write-locked, the target record does not exist, the target record was already replaced,
136/// or tag authorization fails. On success the correction is stored at the trail's current monotonic sequence
137/// number and a `RecordAdded` event is emitted.
138#[derive(Debug, Clone)]
139pub struct CorrectRecord {
140    /// Trail object ID that will receive the correction.
141    pub trail_id: ObjectId,
142    /// Address authorizing the correction.
143    pub owner: Address,
144    /// Sequence number of the record being corrected.
145    pub sequence_number: u64,
146    /// Correction payload to append.
147    pub data: Data,
148    /// Optional application-defined metadata.
149    pub metadata: Option<String>,
150    /// Optional trail-owned tag to attach to the correction record.
151    pub tag: Option<String>,
152    /// Explicit capability to use instead of auto-selecting one from the owner's wallet.
153    pub selected_capability_id: Option<ObjectId>,
154    cached_ptb: OnceCell<ProgrammableTransaction>,
155}
156
157impl CorrectRecord {
158    /// Creates a `CorrectRecord` transaction builder payload.
159    ///
160    /// The resulting transaction appends a correction record for `sequence_number` and carries the same
161    /// authorization, write-lock, record-existence, already-replaced, tag-definition, and tag-authorization
162    /// requirements as the Move `correct_record` entry point.
163    pub fn new(
164        trail_id: ObjectId,
165        owner: Address,
166        sequence_number: u64,
167        data: Data,
168        metadata: Option<String>,
169        tag: Option<String>,
170        selected_capability_id: Option<ObjectId>,
171    ) -> Self {
172        Self {
173            trail_id,
174            owner,
175            sequence_number,
176            data,
177            metadata,
178            tag,
179            selected_capability_id,
180            cached_ptb: OnceCell::new(),
181        }
182    }
183
184    async fn make_ptb<C>(&self, client: &C) -> Result<ProgrammableTransaction, Error>
185    where
186        C: CoreClientReadOnly + OptionalSync,
187    {
188        RecordsOps::correct_record(
189            client,
190            self.trail_id,
191            self.owner,
192            self.sequence_number,
193            RecordInput::new(self.data.clone(), self.metadata.clone(), self.tag.clone()),
194            self.selected_capability_id,
195        )
196        .await
197    }
198}
199
200#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))]
201#[cfg_attr(feature = "send-sync", async_trait)]
202impl Transaction for CorrectRecord {
203    type Error = Error;
204    type Output = RecordAdded;
205
206    async fn build_programmable_transaction<C>(&self, client: &C) -> Result<ProgrammableTransaction, Self::Error>
207    where
208        C: CoreClientReadOnly + OptionalSync,
209    {
210        self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned()
211    }
212
213    async fn apply_with_events<C>(
214        mut self,
215        _: &mut IotaTransactionBlockEffects,
216        events: &mut IotaTransactionBlockEvents,
217        _: &C,
218    ) -> Result<Self::Output, Self::Error>
219    where
220        C: CoreClientReadOnly + OptionalSync,
221    {
222        let event = events
223            .data
224            .iter()
225            .find_map(|data| serde_json::from_value::<Event<RecordAdded>>(data.parsed_json.clone()).ok())
226            .ok_or_else(|| Error::UnexpectedApiResponse("RecordAdded event not found".to_string()))?;
227
228        Ok(event.data)
229    }
230
231    async fn apply<C>(self, effects: &mut IotaTransactionBlockEffects, client: &C) -> Result<Self::Output, Self::Error>
232    where
233        C: CoreClientReadOnly + OptionalSync,
234    {
235        tx::apply_with_events(self, effects, client).await
236    }
237}
238
239// ===== DeleteRecord =====
240
241/// Transaction that deletes a single record.
242///
243/// Requires the `DeleteRecord` permission. The Move package aborts with `ERecordNotFound` when no record
244/// exists at `sequence_number` and with `ERecordLocked` while the configured delete-record window still
245/// protects the record. Tag-aware authorization additionally applies: if the record carries a tag, the
246/// supplied capability's role must allow that tag.
247///
248/// On success a `RecordDeleted` event is emitted.
249#[derive(Debug, Clone)]
250pub struct DeleteRecord {
251    /// Trail object ID containing the record.
252    pub trail_id: ObjectId,
253    /// Address authorizing the deletion.
254    pub owner: Address,
255    /// Sequence number of the record to delete.
256    pub sequence_number: u64,
257    /// Explicit capability to use instead of auto-selecting one from the owner's wallet.
258    pub selected_capability_id: Option<ObjectId>,
259    cached_ptb: OnceCell<ProgrammableTransaction>,
260}
261
262impl DeleteRecord {
263    /// Creates a `DeleteRecord` transaction builder payload.
264    pub fn new(
265        trail_id: ObjectId,
266        owner: Address,
267        sequence_number: u64,
268        selected_capability_id: Option<ObjectId>,
269    ) -> Self {
270        Self {
271            trail_id,
272            owner,
273            sequence_number,
274            selected_capability_id,
275            cached_ptb: OnceCell::new(),
276        }
277    }
278
279    async fn make_ptb<C>(&self, client: &C) -> Result<ProgrammableTransaction, Error>
280    where
281        C: CoreClientReadOnly + OptionalSync,
282    {
283        RecordsOps::delete_record(
284            client,
285            self.trail_id,
286            self.owner,
287            self.sequence_number,
288            self.selected_capability_id,
289        )
290        .await
291    }
292}
293
294#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))]
295#[cfg_attr(feature = "send-sync", async_trait)]
296impl Transaction for DeleteRecord {
297    type Error = Error;
298    type Output = RecordDeleted;
299
300    async fn build_programmable_transaction<C>(&self, client: &C) -> Result<ProgrammableTransaction, Self::Error>
301    where
302        C: CoreClientReadOnly + OptionalSync,
303    {
304        self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned()
305    }
306
307    async fn apply_with_events<C>(
308        mut self,
309        _: &mut IotaTransactionBlockEffects,
310        events: &mut IotaTransactionBlockEvents,
311        _: &C,
312    ) -> Result<Self::Output, Self::Error>
313    where
314        C: CoreClientReadOnly + OptionalSync,
315    {
316        let event = events
317            .data
318            .iter()
319            .find_map(|data| serde_json::from_value::<Event<RecordDeleted>>(data.parsed_json.clone()).ok())
320            .ok_or_else(|| Error::UnexpectedApiResponse("RecordDeleted event not found".to_string()))?;
321
322        Ok(event.data)
323    }
324
325    async fn apply<C>(self, effects: &mut IotaTransactionBlockEffects, client: &C) -> Result<Self::Output, Self::Error>
326    where
327        C: CoreClientReadOnly + OptionalSync,
328    {
329        tx::apply_with_events(self, effects, client).await
330    }
331}
332
333// ===== DeleteRecordsBatch =====
334
335/// Transaction that deletes multiple records in a batch operation.
336///
337/// Requires the `DeleteAllRecords` permission. The Move entry point walks the trail from the front,
338/// silently skips records still inside the delete-record window or outside the capability's allowed tag set,
339/// and deletes up to `limit` eligible records in trail order.
340///
341/// On success a `RecordDeleted` event is emitted per deletion.
342///
343/// `limit` caps the number of records actually deleted, not the number of records inspected. Ineligible
344/// records at the front of the trail are silently walked past without counting toward `limit`, so more
345/// than `limit` records may be visited before `limit` deletions accumulate.
346///
347/// Lock state — both count-based
348/// and time-based — is evaluated against the trail snapshot and clock timestamp captured at the start of the
349/// call, so the deletable set is stable for the batch's duration. The Rust implementation mirrors the Move
350/// output by collecting the matching `RecordDeleted` events in deletion order; the returned vector may be
351/// shorter than `limit` (or empty) and that is not an error.
352#[derive(Debug, Clone)]
353pub struct DeleteRecordsBatch {
354    /// Trail object ID containing the records.
355    pub trail_id: ObjectId,
356    /// Address authorizing the deletion.
357    pub owner: Address,
358    /// Maximum number of records to delete in this batch.
359    pub limit: u64,
360    /// Explicit capability to use instead of auto-selecting one from the owner's wallet.
361    pub selected_capability_id: Option<ObjectId>,
362    cached_ptb: OnceCell<ProgrammableTransaction>,
363}
364
365impl DeleteRecordsBatch {
366    /// Creates a `DeleteRecordsBatch` transaction builder payload.
367    pub fn new(trail_id: ObjectId, owner: Address, limit: u64, selected_capability_id: Option<ObjectId>) -> Self {
368        Self {
369            trail_id,
370            owner,
371            limit,
372            selected_capability_id,
373            cached_ptb: OnceCell::new(),
374        }
375    }
376
377    async fn make_ptb<C>(&self, client: &C) -> Result<ProgrammableTransaction, Error>
378    where
379        C: CoreClientReadOnly + OptionalSync,
380    {
381        RecordsOps::delete_records_batch(
382            client,
383            self.trail_id,
384            self.owner,
385            self.limit,
386            self.selected_capability_id,
387        )
388        .await
389    }
390}
391
392#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))]
393#[cfg_attr(feature = "send-sync", async_trait)]
394impl Transaction for DeleteRecordsBatch {
395    type Error = Error;
396    type Output = Vec<u64>;
397
398    async fn build_programmable_transaction<C>(&self, client: &C) -> Result<ProgrammableTransaction, Self::Error>
399    where
400        C: CoreClientReadOnly + OptionalSync,
401    {
402        self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned()
403    }
404
405    async fn apply_with_events<C>(
406        self,
407        _: &mut IotaTransactionBlockEffects,
408        events: &mut IotaTransactionBlockEvents,
409        _: &C,
410    ) -> Result<Self::Output, Self::Error>
411    where
412        C: CoreClientReadOnly + OptionalSync,
413    {
414        let deleted = events
415            .data
416            .iter()
417            .filter_map(|data| serde_json::from_value::<Event<RecordDeleted>>(data.parsed_json.clone()).ok())
418            .map(|event| event.data.sequence_number)
419            .collect();
420
421        Ok(deleted)
422    }
423
424    async fn apply<C>(self, effects: &mut IotaTransactionBlockEffects, client: &C) -> Result<Self::Output, Self::Error>
425    where
426        C: CoreClientReadOnly + OptionalSync,
427    {
428        tx::apply_with_events(self, effects, client).await
429    }
430}