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};
7
8use iota_interaction::move_core_types::annotated_value::MoveValue;
9use iota_interaction::rpc_types::IotaMoveValue;
10use iota_interaction::types::base_types::{ObjectID, TypeTag};
11use iota_interaction::types::collection_types::LinkedTable;
12use iota_interaction::types::dynamic_field::DynamicFieldName;
13use iota_interaction::{IotaKeySignature, OptionalSync};
14use product_common::core_client::{CoreClient, CoreClientReadOnly};
15use product_common::transaction::transaction_builder::TransactionBuilder;
16use secret_storage::Signer;
17use serde::de::DeserializeOwned;
18
19use crate::core::internal::{linked_table, trail as trail_reader};
20use crate::core::trail::{AuditTrailFull, AuditTrailReadOnly};
21use crate::core::types::{Data, PaginatedRecord, Record};
22use crate::error::Error;
23
24mod operations;
25mod transactions;
26
27pub use transactions::{AddRecord, 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: std::marker::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: std::marker::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    /// Placeholder for a future correction helper.
152    ///
153    /// # Errors
154    ///
155    /// Always returns [`Error::NotImplemented`].
156    pub async fn correct(&self, _replaces: Vec<u64>, _data: D, _metadata: Option<String>) -> Result<(), Error>
157    where
158        C: AuditTrailFull,
159    {
160        Err(Error::NotImplemented("TrailRecords::correct"))
161    }
162
163    /// Returns the number of records currently stored in the trail.
164    ///
165    /// # Errors
166    ///
167    /// Returns an error if the count cannot be computed from the current on-chain state.
168    pub async fn record_count(&self) -> Result<u64, Error>
169    where
170        C: AuditTrailReadOnly,
171    {
172        let tx = RecordsOps::record_count(self.client, self.trail_id).await?;
173        self.client.execute_read_only_transaction(tx).await
174    }
175
176    /// Lists all records into a [`HashMap`].
177    ///
178    /// This traverses the full on-chain linked table and can be expensive for large trails.
179    /// For paginated access, use [`list_page`](Self::list_page).
180    pub async fn list(&self) -> Result<HashMap<u64, Record<D>>, Error>
181    where
182        C: AuditTrailReadOnly,
183        D: DeserializeOwned,
184    {
185        let records_table = self.load_records_table().await?;
186        list_linked_table::<_, Record<D>>(self.client, &records_table, None).await
187    }
188
189    /// Lists all records with a hard cap to protect against expensive traversals.
190    pub async fn list_with_limit(&self, max_entries: usize) -> Result<HashMap<u64, Record<D>>, Error>
191    where
192        C: AuditTrailReadOnly,
193        D: DeserializeOwned,
194    {
195        let records_table = self.load_records_table().await?;
196        list_linked_table::<_, Record<D>>(self.client, &records_table, Some(max_entries)).await
197    }
198
199    /// Lists one page of linked-table records starting from `cursor`.
200    ///
201    /// Pass `None` for the first page; use `next_cursor` for subsequent pages.
202    pub async fn list_page(&self, cursor: Option<u64>, limit: usize) -> Result<PaginatedRecord<D>, Error>
203    where
204        C: AuditTrailReadOnly,
205        D: DeserializeOwned,
206    {
207        if limit > MAX_LIST_PAGE_LIMIT {
208            return Err(Error::InvalidArgument(format!(
209                "page limit {limit} exceeds max supported page size {MAX_LIST_PAGE_LIMIT}"
210            )));
211        }
212
213        let records_table = self.load_records_table().await?;
214        let (records, next_cursor) =
215            list_linked_table_page::<_, Record<D>>(self.client, &records_table, cursor, limit).await?;
216
217        Ok(PaginatedRecord {
218            has_next_page: next_cursor.is_some(),
219            next_cursor,
220            records,
221        })
222    }
223
224    async fn load_records_table(&self) -> Result<LinkedTable<u64>, Error>
225    where
226        C: AuditTrailReadOnly,
227    {
228        trail_reader::get_audit_trail(self.trail_id, self.client)
229            .await
230            .map(|on_chain_trail| on_chain_trail.records)
231    }
232}
233
234async fn list_linked_table_page<C, V>(
235    client: &C,
236    table: &LinkedTable<u64>,
237    start_key: Option<u64>,
238    limit: usize,
239) -> Result<(BTreeMap<u64, V>, Option<u64>), Error>
240where
241    C: CoreClientReadOnly + OptionalSync,
242    V: DeserializeOwned,
243{
244    // Preserve linked-table order while exposing a page as a stable Rust map keyed by sequence number.
245    if limit == 0 {
246        return Ok((BTreeMap::new(), start_key.or(table.head)));
247    }
248
249    let mut cursor = start_key.or(table.head);
250    let mut items = BTreeMap::new();
251
252    for _ in 0..limit {
253        let Some(key) = cursor else { break };
254
255        if items.contains_key(&key) {
256            return Err(Error::UnexpectedApiResponse(format!(
257                "cycle detected while traversing linked-table {table_id}; repeated key {key}",
258                table_id = table.id
259            )));
260        }
261
262        let node = linked_table::fetch_node::<_, u64, V>(
263            client,
264            table.id,
265            DynamicFieldName {
266                type_: TypeTag::U64,
267                value: IotaMoveValue::from(MoveValue::U64(key)).to_json_value(),
268            },
269        )
270        .await?;
271
272        cursor = node.next;
273        items.insert(key, node.value);
274    }
275
276    Ok((items, cursor))
277}
278
279async fn list_linked_table<C, V>(
280    client: &C,
281    table: &LinkedTable<u64>,
282    max_entries: Option<usize>,
283) -> Result<HashMap<u64, V>, Error>
284where
285    C: CoreClientReadOnly + OptionalSync,
286    V: DeserializeOwned,
287{
288    // Full traversal is only allowed when the caller explicitly accepts the current linked-table size.
289    let expected = table.size as usize;
290    let cap = max_entries.unwrap_or(expected);
291
292    if expected > cap {
293        return Err(Error::InvalidArgument(format!(
294            "linked-table size {expected} exceeds max_entries {cap}"
295        )));
296    }
297
298    let (entries, next_key) = list_linked_table_page(client, table, None, expected).await?;
299
300    if entries.len() != expected {
301        return Err(Error::UnexpectedApiResponse(format!(
302            "linked-table traversal mismatch; expected {expected} entries, got {}",
303            entries.len()
304        )));
305    }
306
307    if next_key.is_some() {
308        return Err(Error::UnexpectedApiResponse(format!(
309            "linked-table traversal has extra entries beyond declared size {expected}"
310        )));
311    }
312
313    Ok(entries.into_iter().collect())
314}