Skip to main content

audit_trails/core/records/
mod.rs

1// Copyright 2020-2026 IOTA Stiftung
2// SPDX-License-Identifier: Apache-2.0
3
4//! Record read and mutation APIs for Audit Trails.
5
6use std::collections::{BTreeMap, HashMap, HashSet};
7use std::marker::PhantomData;
8
9use iota_interaction::move_core_types::annotated_value::MoveValue;
10use iota_interaction::rpc_types::IotaMoveValue;
11use iota_interaction::types::collection_types::LinkedTable;
12use iota_interaction::types::dynamic_field::DynamicFieldName;
13use iota_interaction::{IotaKeySignature, OptionalSync};
14use iota_sdk_types::{ObjectId, TypeTag};
15use product_common::core_client::{CoreClient, CoreClientReadOnly};
16use product_common::transaction::transaction_builder::TransactionBuilder;
17use secret_storage::Signer;
18use serde::de::DeserializeOwned;
19
20use crate::core::internal::{linked_table, trail as trail_reader};
21use crate::core::trail::{AuditTrailFull, AuditTrailReadOnly};
22use crate::core::types::{Data, PaginatedRecord, Record, RecordInput};
23use crate::error::Error;
24mod operations;
25mod transactions;
26
27pub use transactions::{AddRecord, CorrectRecord, DeleteRecord, DeleteRecordsBatch};
28
29use self::operations::RecordsOps;
30
31const MAX_LIST_PAGE_LIMIT: usize = 1_000;
32
33/// Record API scoped to a specific trail.
34///
35/// This handle builds record-oriented transactions and loads record data from the trail's linked-table storage.
36#[derive(Debug, Clone)]
37pub struct TrailRecords<'a, C, D = Data> {
38    pub(crate) client: &'a C,
39    pub(crate) trail_id: ObjectId,
40    pub(crate) selected_capability_id: Option<ObjectId>,
41    pub(crate) _phantom: PhantomData<D>,
42}
43
44impl<'a, C, D> TrailRecords<'a, C, D> {
45    pub(crate) fn new(client: &'a C, trail_id: ObjectId, selected_capability_id: Option<ObjectId>) -> Self {
46        Self {
47            client,
48            trail_id,
49            selected_capability_id,
50            _phantom: PhantomData,
51        }
52    }
53
54    /// Uses the provided capability as the auth capability for subsequent write operations.
55    pub fn using_capability(mut self, capability_id: ObjectId) -> Self {
56        self.selected_capability_id = Some(capability_id);
57        self
58    }
59
60    /// Loads a single record by sequence number.
61    ///
62    /// # Errors
63    ///
64    /// Returns an error if the record cannot be loaded or deserialized.
65    pub async fn get(&self, sequence_number: u64) -> Result<Record<D>, Error>
66    where
67        C: AuditTrailReadOnly,
68        D: DeserializeOwned,
69    {
70        let tx = RecordsOps::get_record(self.client, self.trail_id, sequence_number).await?;
71        self.client.execute_read_only_transaction(tx).await
72    }
73
74    /// Builds a transaction that appends a record to the trail.
75    ///
76    /// Tagged writes must reference a tag already defined on the trail. They also require a capability whose
77    /// role allows both `AddRecord` and the requested tag.
78    pub fn add<S>(&self, data: D, metadata: Option<String>, tag: Option<String>) -> TransactionBuilder<AddRecord>
79    where
80        C: AuditTrailFull + CoreClient<S>,
81        S: Signer<IotaKeySignature> + OptionalSync,
82        D: Into<Data>,
83    {
84        let owner = self.client.sender_address();
85        TransactionBuilder::new(AddRecord::new(
86            self.trail_id,
87            owner,
88            data.into(),
89            metadata,
90            tag,
91            self.selected_capability_id,
92        ))
93    }
94
95    /// Builds a transaction that deletes a single record.
96    ///
97    /// Deletion remains subject to record locking rules and tag-based access restrictions enforced on-chain.
98    pub fn delete<S>(&self, sequence_number: u64) -> TransactionBuilder<DeleteRecord>
99    where
100        C: AuditTrailFull + CoreClient<S>,
101        S: Signer<IotaKeySignature> + OptionalSync,
102    {
103        let owner = self.client.sender_address();
104        TransactionBuilder::new(DeleteRecord::new(
105            self.trail_id,
106            owner,
107            sequence_number,
108            self.selected_capability_id,
109        ))
110    }
111
112    /// Builds a transaction that deletes up to `limit` records in one operation.
113    ///
114    /// Batch deletion requires `DeleteAllRecords`, walks the trail from the front in sequence order, and silently
115    /// skips records that are either locked or whose tag is not in the capability's allowed set. The returned
116    /// vector contains the sequence numbers actually deleted in deletion order; it may be shorter than `limit`
117    /// (or empty) when records are skipped or the trail runs out of records before `limit` is reached.
118    ///
119    /// # Locking semantics
120    ///
121    /// The set of locked records is fixed at the start of the transaction. For count-based windows, the protected
122    /// window is the last `count` records present when the call begins — records this same call deletes do not
123    /// change which other records are protected. Time-based locks are evaluated against the clock timestamp
124    /// captured at the start of the call. Running `delete_records_batch(limit)` therefore produces the same
125    /// final trail state as invoking `delete_record` once per deletable sequence number, as long as the locking
126    /// configuration is not mutated and no new records are added between calls.
127    ///
128    /// # Caveats
129    ///
130    /// - **Partial progress is not an error.** An empty returned vector means every front-to-back candidate was either
131    ///   locked or tag-filtered out.
132    /// - **Tag filtering is silent.** A capability with a narrow tag scope can make the batch appear to stop early
133    ///   while locked-and-disallowed records still exist further back.
134    /// - **Gas and object-size limits.** The call walks and mutates inline; prefer modest `limit` values and repeat the
135    ///   call rather than passing a single large `limit`.
136    /// - **Order is fixed.** Use [`Self::delete`] to target specific sequence numbers.
137    pub fn delete_records_batch<S>(&self, limit: u64) -> TransactionBuilder<DeleteRecordsBatch>
138    where
139        C: AuditTrailFull + CoreClient<S>,
140        S: Signer<IotaKeySignature> + OptionalSync,
141    {
142        let owner = self.client.sender_address();
143        TransactionBuilder::new(DeleteRecordsBatch::new(
144            self.trail_id,
145            owner,
146            limit,
147            self.selected_capability_id,
148        ))
149    }
150
151    /// Builds a transaction that appends a correction record to the trail.
152    ///
153    /// The original record remains immutable. The correction is appended with a new sequence number and records
154    /// that it supersedes `sequence_number`; the corrected record receives a back-pointer to the correction so
155    /// [`Self::resolve_current`] can follow the replacement chain.
156    ///
157    /// Requires `CorrectRecord`. Tagged corrections require the correction tag to exist in the trail registry
158    /// and the capability's role to allow both the replaced record's tag, when present, and the correction's
159    /// tag, when present. The transaction can fail if the trail package version is incompatible, the capability
160    /// is invalid, the trail is write-locked, the target record does not exist, the target record was already
161    /// replaced, or tag authorization fails. On success a `RecordAdded` event is emitted.
162    pub fn correct<S>(&self, sequence_number: u64, record: RecordInput<D>) -> TransactionBuilder<CorrectRecord>
163    where
164        C: AuditTrailFull + CoreClient<S>,
165        S: Signer<IotaKeySignature> + OptionalSync,
166        D: Into<Data>,
167    {
168        let owner = self.client.sender_address();
169        TransactionBuilder::new(CorrectRecord::new(
170            self.trail_id,
171            owner,
172            sequence_number,
173            record.data.into(),
174            record.metadata,
175            record.tag,
176            self.selected_capability_id,
177        ))
178    }
179
180    /// Loads the current version of a record by following correction links.
181    ///
182    /// Use [`Self::get`] when you need the exact immutable record stored at a sequence number. Use
183    /// `resolve_current` when you have an original sequence number and want the latest correction in that
184    /// record's replacement chain.
185    ///
186    /// For example, if record `3` was corrected by record `7`, and record `7` was later corrected by record
187    /// `9`, `resolve_current(3)` returns record `9`. If the starting record has not been replaced, this returns
188    /// the starting record itself.
189    ///
190    /// # Errors
191    ///
192    /// Returns an error if any record in the replacement chain cannot be loaded, or if the chain contains a
193    /// cycle.
194    pub async fn resolve_current(&self, sequence_number: u64) -> Result<Record<D>, Error>
195    where
196        C: AuditTrailReadOnly,
197        D: DeserializeOwned,
198    {
199        let mut current = sequence_number;
200        let mut visited = HashSet::new();
201
202        loop {
203            if !visited.insert(current) {
204                return Err(Error::UnexpectedApiResponse(format!(
205                    "cycle detected while resolving correction chain at record {current}"
206                )));
207            }
208
209            let record = self.get(current).await?;
210            let Some(next) = record.correction.is_replaced_by else {
211                return Ok(record);
212            };
213            current = next;
214        }
215    }
216
217    /// Returns the number of records currently stored in the trail.
218    ///
219    /// # Errors
220    ///
221    /// Returns an error if the count cannot be computed from the current on-chain state.
222    pub async fn record_count(&self) -> Result<u64, Error>
223    where
224        C: AuditTrailReadOnly,
225    {
226        let tx = RecordsOps::record_count(self.client, self.trail_id).await?;
227        self.client.execute_read_only_transaction(tx).await
228    }
229
230    /// Lists all records into a [`HashMap`].
231    ///
232    /// This traverses the full on-chain linked table and can be expensive for large trails.
233    /// For paginated access, use [`list_page`](Self::list_page).
234    pub async fn list(&self) -> Result<HashMap<u64, Record<D>>, Error>
235    where
236        C: AuditTrailReadOnly,
237        D: DeserializeOwned,
238    {
239        let records_table = self.load_records_table().await?;
240        list_linked_table::<_, Record<D>>(self.client, &records_table, None).await
241    }
242
243    /// Lists all records with a hard cap to protect against expensive traversals.
244    pub async fn list_with_limit(&self, max_entries: usize) -> Result<HashMap<u64, Record<D>>, Error>
245    where
246        C: AuditTrailReadOnly,
247        D: DeserializeOwned,
248    {
249        let records_table = self.load_records_table().await?;
250        list_linked_table::<_, Record<D>>(self.client, &records_table, Some(max_entries)).await
251    }
252
253    /// Lists one page of linked-table records starting from `cursor`.
254    ///
255    /// Pass `None` for the first page; use `next_cursor` for subsequent pages.
256    pub async fn list_page(&self, cursor: Option<u64>, limit: usize) -> Result<PaginatedRecord<D>, Error>
257    where
258        C: AuditTrailReadOnly,
259        D: DeserializeOwned,
260    {
261        if limit > MAX_LIST_PAGE_LIMIT {
262            return Err(Error::InvalidArgument(format!(
263                "page limit {limit} exceeds max supported page size {MAX_LIST_PAGE_LIMIT}"
264            )));
265        }
266
267        let records_table = self.load_records_table().await?;
268        let (records, next_cursor) =
269            list_linked_table_page::<_, Record<D>>(self.client, &records_table, cursor, limit).await?;
270
271        Ok(PaginatedRecord {
272            has_next_page: next_cursor.is_some(),
273            next_cursor,
274            records,
275        })
276    }
277
278    async fn load_records_table(&self) -> Result<LinkedTable<u64>, Error>
279    where
280        C: AuditTrailReadOnly,
281    {
282        trail_reader::get_audit_trail(self.trail_id, self.client)
283            .await
284            .map(|on_chain_trail| on_chain_trail.records)
285    }
286}
287
288async fn list_linked_table_page<C, V>(
289    client: &C,
290    table: &LinkedTable<u64>,
291    start_key: Option<u64>,
292    limit: usize,
293) -> Result<(BTreeMap<u64, V>, Option<u64>), Error>
294where
295    C: CoreClientReadOnly + OptionalSync,
296    V: DeserializeOwned,
297{
298    // Preserve linked-table order while exposing a page as a stable Rust map keyed by sequence number.
299    if limit == 0 {
300        return Ok((BTreeMap::new(), start_key.or(table.head)));
301    }
302
303    let mut cursor = start_key.or(table.head);
304    let mut items = BTreeMap::new();
305
306    for _ in 0..limit {
307        let Some(key) = cursor else { break };
308
309        if items.contains_key(&key) {
310            return Err(Error::UnexpectedApiResponse(format!(
311                "cycle detected while traversing linked-table {table_id}; repeated key {key}",
312                table_id = table.id
313            )));
314        }
315
316        let node = linked_table::fetch_node::<_, u64, V>(
317            client,
318            table.id,
319            DynamicFieldName {
320                type_: TypeTag::U64,
321                value: IotaMoveValue::from(MoveValue::U64(key)).to_json_value(),
322            },
323        )
324        .await?;
325
326        cursor = node.next;
327        items.insert(key, node.value);
328    }
329
330    Ok((items, cursor))
331}
332
333async fn list_linked_table<C, V>(
334    client: &C,
335    table: &LinkedTable<u64>,
336    max_entries: Option<usize>,
337) -> Result<HashMap<u64, V>, Error>
338where
339    C: CoreClientReadOnly + OptionalSync,
340    V: DeserializeOwned,
341{
342    // Full traversal is only allowed when the caller explicitly accepts the current linked-table size.
343    let expected = table.size as usize;
344    let cap = max_entries.unwrap_or(expected);
345
346    if expected > cap {
347        return Err(Error::InvalidArgument(format!(
348            "linked-table size {expected} exceeds max_entries {cap}"
349        )));
350    }
351
352    let (entries, next_key) = list_linked_table_page(client, table, None, expected).await?;
353
354    if entries.len() != expected {
355        return Err(Error::UnexpectedApiResponse(format!(
356            "linked-table traversal mismatch; expected {expected} entries, got {}",
357            entries.len()
358        )));
359    }
360
361    if next_key.is_some() {
362        return Err(Error::UnexpectedApiResponse(format!(
363            "linked-table traversal has extra entries beyond declared size {expected}"
364        )));
365    }
366
367    Ok(entries.into_iter().collect())
368}