From 37abfa94ea0f46ea7b167a428907d9da68eb9601 Mon Sep 17 00:00:00 2001 From: Yasir Date: Tue, 23 Jun 2026 16:38:18 +0300 Subject: [PATCH 1/9] feat: add correction functionality to audit trail records --- audit-trail-move/sources/audit_trail.move | 107 ++++- audit-trail-move/sources/record.move | 5 + audit-trail-move/tests/record_tests.move | 409 +++++++++++++++++- .../src/core/internal/capability.rs | 32 +- .../src/core/internal/linked_table.rs | 25 +- audit-trail-rs/src/core/records/mod.rs | 83 +++- audit-trail-rs/src/core/records/operations.rs | 75 +++- .../src/core/records/transactions.rs | 109 +++++ audit-trail-rs/tests/e2e/records.rs | 177 ++++++++ bindings/wasm/audit_trail_wasm/src/trail.rs | 51 ++- .../src/trail_handle/records.rs | 69 ++- 11 files changed, 1098 insertions(+), 44 deletions(-) diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index 829e85ad..39f302ba 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -21,7 +21,7 @@ use audit_trails::{ record::{Self, Record, InitialRecord}, record_tags::{Self, RoleTags, TagRegistry} }; -use iota::{clock::{Self, Clock}, event, linked_table::{Self, LinkedTable}, vec_set::VecSet}; +use iota::{clock::{Self, Clock}, event, linked_table::{Self, LinkedTable}, vec_set::{Self, VecSet}}; use std::string::String; use tf_components::{capability::Capability, role_map::{Self, RoleMap}, timelock::TimeLock}; @@ -51,6 +51,8 @@ const ERecordTagAlreadyDefined: vector = #[error] const ERecordTagInUse: vector = b"The requested tag cannot be removed because it is already used by an existing record or role"; +#[error] +const ERecordAlreadyReplaced: vector = b"The record has already been replaced"; // ===== Constants ===== @@ -533,6 +535,109 @@ public fun add_record( output } +/// Adds a correction record that supersedes an existing record. +/// +/// The original record remains immutable. The new correction record is appended +/// at the next sequence number with a correction tracker whose `replaces` set +/// contains `sequence_number`. The replaced record receives an `is_replaced_by` +/// back-pointer to the new correction so clients can resolve the current +/// canonical record by following the replacement chain. +/// +/// Requires a capability granting the `CorrectRecord` permission. When either +/// the replaced record or the new correction record carries a tag, that same +/// capability must also allow the corresponding tag. +/// +/// Aborts with: +/// * `EPackageVersionMismatch` when the trail is at a different package version. +/// * any error documented by `RoleMap::assert_capability_valid` when `cap` fails +/// authorization checks. +/// * `ETrailWriteLocked` while `write_lock` is active. +/// * `ERecordNotFound` when no record exists at `sequence_number`. +/// * `ERecordAlreadyReplaced` when `sequence_number` already points to a newer +/// correction. +/// * `ERecordTagNotDefined` when `record_tag` is not in the trail's tag registry. +/// * `ERecordTagNotAllowed` when `cap`'s role does not allow the replaced +/// record tag or the new correction tag. +/// +/// Emits a `RecordAdded` event for the correction record on success. +/// +/// Returns the same receipt that is emitted as the `RecordAdded` event. +public fun correct_record( + self: &mut AuditTrail, + cap: &Capability, + sequence_number: u64, + stored_data: D, + record_metadata: Option, + record_tag: Option, + clock: &Clock, + ctx: &mut TxContext, +): RecordAdded { + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + self + .roles + .assert_capability_valid( + cap, + &permission::correct_record(), + clock, + ctx, + ); + assert!(!locking::is_write_locked(&self.locking_config, clock), ETrailWriteLocked); + assert!(linked_table::contains(&self.records, sequence_number), ERecordNotFound); + assert!( + !record::is_replaced(record::correction(self.records.borrow(sequence_number))), + ERecordAlreadyReplaced, + ); + assert!( + is_record_tag_allowed( + self, + cap, + self.records.borrow(sequence_number).tag(), + ), + ERecordTagNotAllowed, + ); + assert!(is_record_tag_allowed(self, cap, &record_tag), ERecordTagNotAllowed); + + let caller = ctx.sender(); + let timestamp = clock::timestamp_ms(clock); + let trail_id = self.id(); + let seq = self.sequence_number; + + if (record_tag.is_some()) { + record_tags::increment_usage_count(&mut self.tags, option::borrow(&record_tag)); + }; + + let mut replaces = vec_set::empty(); + replaces.insert(sequence_number); + + let correction = record::new( + stored_data, + record_metadata, + record_tag, + seq, + caller, + timestamp, + record::with_replaces(replaces), + ); + + record::set_replaced_by( + record::correction_mut(linked_table::borrow_mut(&mut self.records, sequence_number)), + seq, + ); + + linked_table::push_back(&mut self.records, seq, correction); + self.sequence_number = self.sequence_number + 1; + + let output = RecordAdded { + trail_id, + sequence_number: seq, + added_by: caller, + timestamp, + }; + + event::emit(copy output); + output +} + /// Deletes the record at `sequence_number` from the trail. /// /// When the deleted record carries a tag, the trail's tag-registry usage count for diff --git a/audit-trail-move/sources/record.move b/audit-trail-move/sources/record.move index c15c24d9..e27afd91 100644 --- a/audit-trail-move/sources/record.move +++ b/audit-trail-move/sources/record.move @@ -171,6 +171,11 @@ public fun correction(self: &Record): &RecordCorrection { &self.correction } +/// Returns a mutable reference to the record's bidirectional correction tracker. +public(package) fun correction_mut(self: &mut Record): &mut RecordCorrection { + &mut self.correction +} + /// Destroys a `Record` by destructuring it. public(package) fun destroy(self: Record) { let Record { diff --git a/audit-trail-move/tests/record_tests.move b/audit-trail-move/tests/record_tests.move index cc218ce6..03e193cb 100644 --- a/audit-trail-move/tests/record_tests.move +++ b/audit-trail-move/tests/record_tests.move @@ -18,7 +18,7 @@ use audit_trails::{ cleanup_trail_and_clock } }; -use iota::{clock, test_scenario as ts}; +use iota::{clock, test_scenario as ts, vec_set}; use std::string; use tf_components::{capability::Capability, timelock}; @@ -173,6 +173,413 @@ fun test_add_tagged_record_with_matching_role_tags() { ts::end(scenario); } +#[test] +fun test_correct_record_appends_correction_and_links_records() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + transfer::public_transfer(admin_cap, admin); + }; + + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail.create_role( + &admin_cap, + string::utf8(b"RecordCorrector"), + permission::record_admin_permissions(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let record_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"RecordCorrector"), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(record_cap, admin); + admin_cap.destroy_for_testing(); + cleanup_trail_and_clock(trail, clock); + }; + + ts::next_tx(&mut scenario, admin); + { + let (record_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + clock.set_for_testing(initial_time_for_testing() + 1000); + + trail.add_record( + &record_cap, + record::new_text(string::utf8(b"Incorrect record")), + std::option::some(string::utf8(b"draft")), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + trail.correct_record( + &record_cap, + 0, + record::new_text(string::utf8(b"Correct record")), + std::option::some(string::utf8(b"approved")), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + assert!(trail.has_record(1), 0); + assert!(trail.record_count() == 2, 1); + + let old_record = trail.get_record(0); + assert!(record::is_replaced(record::correction(old_record)), 2); + assert!(record::is_replaced_by(record::correction(old_record)) == std::option::some(1), 3); + + let correction_record = trail.get_record(1); + assert!(record::is_correction(record::correction(correction_record)), 4); + let replaced_seq = 0; + assert!( + vec_set::contains(record::replaces(record::correction(correction_record)), &replaced_seq), + 5, + ); + + cleanup_capability_trail_and_clock(&scenario, record_cap, trail, clock); + }; + + ts::end(scenario); +} + +#[test] +fun test_correct_record_can_use_different_allowed_tag() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail_with_tags( + &mut scenario, + locking_config, + std::option::none(), + vector[string::utf8(b"finance"), string::utf8(b"legal")], + ); + transfer::public_transfer(admin_cap, admin); + }; + + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail.create_role( + &admin_cap, + string::utf8(b"TaggedCorrector"), + permission::record_admin_permissions(), + std::option::some(record_tags::new_role_tags(vector[ + string::utf8(b"finance"), + string::utf8(b"legal"), + ])), + &clock, + ts::ctx(&mut scenario), + ); + + let record_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"TaggedCorrector"), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(record_cap, admin); + admin_cap.destroy_for_testing(); + cleanup_trail_and_clock(trail, clock); + }; + + ts::next_tx(&mut scenario, admin); + { + let (record_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + clock.set_for_testing(initial_time_for_testing() + 1000); + + trail.add_record( + &record_cap, + record::new_text(string::utf8(b"Finance record")), + std::option::none(), + std::option::some(string::utf8(b"finance")), + &clock, + ts::ctx(&mut scenario), + ); + + trail.correct_record( + &record_cap, + 0, + record::new_text(string::utf8(b"Legal correction")), + std::option::none(), + std::option::some(string::utf8(b"legal")), + &clock, + ts::ctx(&mut scenario), + ); + + let old_record = trail.get_record(0); + assert!(*record::tag(old_record) == std::option::some(string::utf8(b"finance")), 0); + assert!(record::is_replaced(record::correction(old_record)), 1); + + let correction_record = trail.get_record(1); + assert!(*record::tag(correction_record) == std::option::some(string::utf8(b"legal")), 2); + assert!(record::is_correction(record::correction(correction_record)), 3); + + cleanup_capability_trail_and_clock(&scenario, record_cap, trail, clock); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = main::ERecordNotFound)] +fun test_correct_record_not_found() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + transfer::public_transfer(admin_cap, admin); + }; + + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail.create_role( + &admin_cap, + string::utf8(b"RecordCorrector"), + permission::record_admin_permissions(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let record_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"RecordCorrector"), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(record_cap, admin); + admin_cap.destroy_for_testing(); + cleanup_trail_and_clock(trail, clock); + }; + + ts::next_tx(&mut scenario, admin); + { + let (record_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + clock.set_for_testing(initial_time_for_testing() + 1000); + + trail.correct_record( + &record_cap, + 999, + record::new_text(string::utf8(b"Correct record")), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, record_cap, trail, clock); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = main::ERecordAlreadyReplaced)] +fun test_correct_record_rejects_replaced_record() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + transfer::public_transfer(admin_cap, admin); + }; + + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail.create_role( + &admin_cap, + string::utf8(b"RecordCorrector"), + permission::record_admin_permissions(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let record_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"RecordCorrector"), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(record_cap, admin); + admin_cap.destroy_for_testing(); + cleanup_trail_and_clock(trail, clock); + }; + + ts::next_tx(&mut scenario, admin); + { + let (record_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + clock.set_for_testing(initial_time_for_testing() + 1000); + + trail.add_record( + &record_cap, + record::new_text(string::utf8(b"Original record")), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + trail.correct_record( + &record_cap, + 0, + record::new_text(string::utf8(b"First correction")), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + trail.correct_record( + &record_cap, + 0, + record::new_text(string::utf8(b"Second correction")), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, record_cap, trail, clock); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = audit_trails::main::ERecordTagNotAllowed)] +fun test_correct_record_requires_matching_new_tag() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail_with_tags( + &mut scenario, + locking_config, + std::option::none(), + vector[string::utf8(b"finance"), string::utf8(b"legal")], + ); + transfer::public_transfer(admin_cap, admin); + }; + + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail.create_role( + &admin_cap, + string::utf8(b"FinanceCorrector"), + permission::record_admin_permissions(), + std::option::some(record_tags::new_role_tags(vector[string::utf8(b"finance")])), + &clock, + ts::ctx(&mut scenario), + ); + + let record_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"FinanceCorrector"), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(record_cap, admin); + admin_cap.destroy_for_testing(); + cleanup_trail_and_clock(trail, clock); + }; + + ts::next_tx(&mut scenario, admin); + { + let (record_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + clock.set_for_testing(initial_time_for_testing() + 1000); + + trail.add_record( + &record_cap, + record::new_text(string::utf8(b"Finance record")), + std::option::none(), + std::option::some(string::utf8(b"finance")), + &clock, + ts::ctx(&mut scenario), + ); + + trail.correct_record( + &record_cap, + 0, + record::new_text(string::utf8(b"Legal correction")), + std::option::none(), + std::option::some(string::utf8(b"legal")), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, record_cap, trail, clock); + }; + + ts::end(scenario); +} + #[test] fun test_delete_tagged_record_with_matching_role_tags() { let admin = @0xAD; diff --git a/audit-trail-rs/src/core/internal/capability.rs b/audit-trail-rs/src/core/internal/capability.rs index 6e7d5d4e..38ab921b 100644 --- a/audit-trail-rs/src/core/internal/capability.rs +++ b/audit-trail-rs/src/core/internal/capability.rs @@ -195,13 +195,37 @@ pub(crate) async fn find_capable_cap_for_tag( where C: CoreClientReadOnly + OptionalSync, { + find_capable_cap_for_tags(client, owner, trail_id, trail, Permission::AddRecord, &[tag]).await +} + +/// Finds an owned capability for an operation that must satisfy tag-aware record authorization. +/// +/// Every tag in `tags` must be allowed by the capability's role. Empty tag lists fall back to ordinary +/// permission-based capability discovery. +pub(crate) async fn find_capable_cap_for_tags( + client: &C, + owner: IotaAddress, + trail_id: ObjectID, + trail: &OnChainAuditTrail, + permission: Permission, + tags: &[&str], +) -> Result +where + C: CoreClientReadOnly + OptionalSync, +{ + if tags.is_empty() { + return find_capable_cap(client, owner, trail_id, trail, permission).await; + } + let valid_roles = trail .roles .roles .iter() .filter(|(_, role)| { - role.permissions.contains(&Permission::AddRecord) - && role.data.as_ref().is_some_and(|record_tags| record_tags.allows(tag)) + role.permissions.contains(&permission) + && tags + .iter() + .all(|tag| role.data.as_ref().is_some_and(|record_tags| record_tags.allows(tag))) }) .map(|(name, _)| name.clone()) .collect::>(); @@ -212,8 +236,8 @@ where .await? .ok_or_else(|| { Error::InvalidArgument(format!( - "no capability with {:?} permission and record tag '{tag}' found for owner {owner} and trail {trail_id}", - Permission::AddRecord + "no capability with {:?} permission and record tags {:?} found for owner {owner} and trail {trail_id}", + permission, tags )) })?; diff --git a/audit-trail-rs/src/core/internal/linked_table.rs b/audit-trail-rs/src/core/internal/linked_table.rs index 7f3f4c85..c62b44d2 100644 --- a/audit-trail-rs/src/core/internal/linked_table.rs +++ b/audit-trail-rs/src/core/internal/linked_table.rs @@ -3,8 +3,10 @@ //! Helpers for reading Move `LinkedTable` nodes through dynamic fields. +use iota_interaction::move_core_types::annotated_value::MoveValue; +use iota_interaction::rpc_types::IotaMoveValue; use iota_interaction::rpc_types::{IotaData as _, IotaObjectDataOptions}; -use iota_interaction::types::base_types::ObjectID; +use iota_interaction::types::base_types::{ObjectID, TypeTag}; use iota_interaction::types::collection_types::LinkedTableNode; use iota_interaction::types::dynamic_field::{DynamicFieldName, Field}; use iota_interaction::{IotaClientTrait, OptionalSync}; @@ -63,3 +65,24 @@ where Ok(field.value) } + +/// Fetches and decodes a linked-table node keyed by a `u64`. +pub(crate) async fn fetch_node_by_key( + client: &C, + table_id: ObjectID, + key: u64, +) -> Result, Error> +where + C: CoreClientReadOnly + OptionalSync, + V: DeserializeOwned, +{ + fetch_node::<_, u64, V>( + client, + table_id, + DynamicFieldName { + type_: TypeTag::U64, + value: IotaMoveValue::from(MoveValue::U64(key)).to_json_value(), + }, + ) + .await +} diff --git a/audit-trail-rs/src/core/records/mod.rs b/audit-trail-rs/src/core/records/mod.rs index 938cfbec..860045fc 100644 --- a/audit-trail-rs/src/core/records/mod.rs +++ b/audit-trail-rs/src/core/records/mod.rs @@ -3,13 +3,10 @@ //! Record read and mutation APIs for Audit Trails. -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, HashMap, HashSet}; -use iota_interaction::move_core_types::annotated_value::MoveValue; -use iota_interaction::rpc_types::IotaMoveValue; -use iota_interaction::types::base_types::{ObjectID, TypeTag}; +use iota_interaction::types::base_types::ObjectID; use iota_interaction::types::collection_types::LinkedTable; -use iota_interaction::types::dynamic_field::DynamicFieldName; use iota_interaction::{IotaKeySignature, OptionalSync}; use product_common::core_client::{CoreClient, CoreClientReadOnly}; use product_common::transaction::transaction_builder::TransactionBuilder; @@ -24,7 +21,7 @@ use crate::error::Error; mod operations; mod transactions; -pub use transactions::{AddRecord, DeleteRecord, DeleteRecordsBatch}; +pub use transactions::{AddRecord, CorrectRecord, DeleteRecord, DeleteRecordsBatch}; use self::operations::RecordsOps; @@ -148,16 +145,70 @@ impl<'a, C, D> TrailRecords<'a, C, D> { )) } - /// Placeholder for a future correction helper. + /// Builds a transaction that appends a correction record to the trail. + /// + /// The original record remains immutable. The correction is appended with a new sequence number and records + /// that it supersedes `sequence_number`. Tagged corrections require a capability whose role allows both the + /// replaced record's tag, when present, and the correction's tag, when present. + pub fn correct( + &self, + sequence_number: u64, + data: D, + metadata: Option, + tag: Option, + ) -> TransactionBuilder + where + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, + D: Into, + { + let owner = self.client.sender_address(); + TransactionBuilder::new(CorrectRecord::new( + self.trail_id, + owner, + sequence_number, + data.into(), + metadata, + tag, + self.selected_capability_id, + )) + } + + /// Loads the current version of a record by following correction links. + /// + /// Use [`Self::get`] when you need the exact immutable record stored at a sequence number. Use + /// `resolve_current` when you have an original sequence number and want the latest correction in that + /// record's replacement chain. + /// + /// For example, if record `3` was corrected by record `7`, and record `7` was later corrected by record + /// `9`, `resolve_current(3)` returns record `9`. If the starting record has not been replaced, this returns + /// the starting record itself. /// /// # Errors /// - /// Always returns [`Error::NotImplemented`]. - pub async fn correct(&self, _replaces: Vec, _data: D, _metadata: Option) -> Result<(), Error> + /// Returns an error if any record in the replacement chain cannot be loaded, or if the chain contains a + /// cycle. + pub async fn resolve_current(&self, sequence_number: u64) -> Result, Error> where - C: AuditTrailFull, + C: AuditTrailReadOnly, + D: DeserializeOwned, { - Err(Error::NotImplemented("TrailRecords::correct")) + let mut current = sequence_number; + let mut visited = HashSet::new(); + + loop { + if !visited.insert(current) { + return Err(Error::UnexpectedApiResponse(format!( + "cycle detected while resolving correction chain at record {current}" + ))); + } + + let record = self.get(current).await?; + let Some(next) = record.correction.is_replaced_by else { + return Ok(record); + }; + current = next; + } } /// Returns the number of records currently stored in the trail. @@ -259,15 +310,7 @@ where ))); } - let node = linked_table::fetch_node::<_, u64, V>( - client, - table.id, - DynamicFieldName { - type_: TypeTag::U64, - value: IotaMoveValue::from(MoveValue::U64(key)).to_json_value(), - }, - ) - .await?; + let node = linked_table::fetch_node_by_key::<_, V>(client, table.id, key).await?; cursor = node.next; items.insert(key, node.value); diff --git a/audit-trail-rs/src/core/records/operations.rs b/audit-trail-rs/src/core/records/operations.rs index af099ca2..cd993d67 100644 --- a/audit-trail-rs/src/core/records/operations.rs +++ b/audit-trail-rs/src/core/records/operations.rs @@ -11,9 +11,9 @@ use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use iota_interaction::types::transaction::ProgrammableTransaction; use product_common::core_client::CoreClientReadOnly; -use crate::core::internal::capability::find_capable_cap_for_tag; -use crate::core::internal::{trail as trail_reader, tx}; -use crate::core::types::{Data, Permission}; +use crate::core::internal::capability::{find_capable_cap_for_tag, find_capable_cap_for_tags}; +use crate::core::internal::{linked_table, trail as trail_reader, tx}; +use crate::core::types::{Data, Permission, Record}; use crate::error::Error; /// Internal namespace for record-related transaction construction. @@ -82,6 +82,75 @@ impl RecordsOps { } } + /// Builds the `correct_record` call. + /// + /// Corrections append a new record that supersedes `sequence_number`. Tagged corrections require a + /// capability whose role grants `CorrectRecord` and allows both the replaced record tag, when present, and + /// the new correction tag, when present. + pub(super) async fn correct_record( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + sequence_number: u64, + data: Data, + record_metadata: Option, + record_tag: Option, + selected_capability_id: Option, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + let package_id = client.package_id(); + let trail = trail_reader::get_audit_trail(trail_id, client).await?; + let replaced = linked_table::fetch_node_by_key::<_, Record>(client, trail.records.id, sequence_number) + .await? + .value; + + if let Some(tag) = record_tag.as_deref() { + if !trail.tags.contains_key(tag) { + return Err(Error::InvalidArgument(format!( + "record tag '{tag}' is not defined for trail {trail_id}" + ))); + } + } + + let cap_ref = if let Some(capability_id) = selected_capability_id { + tx::get_object_ref_by_id(client, &capability_id).await? + } else { + let mut required_tags = Vec::new(); + if let Some(tag) = replaced.tag.as_deref() { + required_tags.push(tag); + } + if let Some(tag) = record_tag.as_deref() + && !required_tags.contains(&tag) + { + required_tags.push(tag); + } + + find_capable_cap_for_tags( + client, + owner, + trail_id, + &trail, + Permission::CorrectRecord, + &required_tags, + ) + .await? + }; + + tx::build_trail_transaction_with_cap_ref(client, trail_id, cap_ref, "correct_record", |ptb, trail_tag| { + data.ensure_matches_tag(trail_tag, package_id)?; + + let seq = tx::ptb_pure(ptb, "sequence_number", sequence_number)?; + let data_arg = data.into_ptb(ptb, package_id)?; + let metadata = tx::ptb_pure(ptb, "record_metadata", record_metadata)?; + let tag = tx::ptb_pure(ptb, "record_tag", record_tag)?; + let clock = tx::get_clock_ref(ptb); + Ok(vec![seq, data_arg, metadata, tag, clock]) + }) + .await + } + /// Builds the `delete_record` call. /// /// Authorization and locking remain enforced by the Move entry point. diff --git a/audit-trail-rs/src/core/records/transactions.rs b/audit-trail-rs/src/core/records/transactions.rs index 882141d9..791a6651 100644 --- a/audit-trail-rs/src/core/records/transactions.rs +++ b/audit-trail-rs/src/core/records/transactions.rs @@ -123,6 +123,115 @@ impl Transaction for AddRecord { } } +// ===== CorrectRecord ===== + +/// Transaction that appends a correction record to a trail. +/// +/// Requires the `CorrectRecord` permission. The new record supersedes `sequence_number` while preserving the +/// original record. Tagged corrections additionally require the tag to exist in the trail registry and the +/// capability's role to allow both the replaced record's tag, when present, and the new correction tag, when +/// present. The package also aborts with `ETrailWriteLocked` while the configured `write_lock` is active. On +/// success the correction is stored at the trail's current monotonic sequence number and a `RecordAdded` event +/// is emitted. +#[derive(Debug, Clone)] +pub struct CorrectRecord { + /// Trail object ID that will receive the correction. + pub trail_id: ObjectID, + /// Address authorizing the correction. + pub owner: IotaAddress, + /// Sequence number of the record being corrected. + pub sequence_number: u64, + /// Correction payload to append. + pub data: Data, + /// Optional application-defined metadata. + pub metadata: Option, + /// Optional trail-owned tag to attach to the correction record. + pub tag: Option, + /// Explicit capability to use instead of auto-selecting one from the owner's wallet. + pub selected_capability_id: Option, + cached_ptb: OnceCell, +} + +impl CorrectRecord { + /// Creates a `CorrectRecord` transaction builder payload. + pub fn new( + trail_id: ObjectID, + owner: IotaAddress, + sequence_number: u64, + data: Data, + metadata: Option, + tag: Option, + selected_capability_id: Option, + ) -> Self { + Self { + trail_id, + owner, + sequence_number, + data, + metadata, + tag, + selected_capability_id, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + RecordsOps::correct_record( + client, + self.trail_id, + self.owner, + self.sequence_number, + self.data.clone(), + self.metadata.clone(), + self.tag.clone(), + self.selected_capability_id, + ) + .await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for CorrectRecord { + type Error = Error; + type Output = RecordAdded; + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply_with_events( + mut self, + _: &mut IotaTransactionBlockEffects, + events: &mut IotaTransactionBlockEvents, + _: &C, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + let event = events + .data + .iter() + .find_map(|data| serde_json::from_value::>(data.parsed_json.clone()).ok()) + .ok_or_else(|| Error::UnexpectedApiResponse("RecordAdded event not found".to_string()))?; + + Ok(event.data) + } + + async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + unreachable!() + } +} + // ===== DeleteRecord ===== /// Transaction that deletes a single record. diff --git a/audit-trail-rs/tests/e2e/records.rs b/audit-trail-rs/tests/e2e/records.rs index c6c34b8e..c37b8696 100644 --- a/audit-trail-rs/tests/e2e/records.rs +++ b/audit-trail-rs/tests/e2e/records.rs @@ -1,6 +1,7 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +use std::collections::HashSet; use std::time::{SystemTime, UNIX_EPOCH}; use audit_trails::core::types::{ @@ -207,6 +208,182 @@ async fn add_record_requires_add_record_permission() -> anyhow::Result<()> { Ok(()) } +#[tokio::test] +async fn correct_record_appends_correction_and_resolves_current_record() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client.create_test_trail(Data::text("correction-root")).await?; + let records = client.trail(trail_id).records(); + + grant_role_capability( + &client, + trail_id, + "RecordCorrector", + [Permission::AddRecord, Permission::CorrectRecord], + ) + .await?; + + let original = records + .add(Data::text("incorrect value"), Some("draft".to_string()), None) + .build_and_execute(&client) + .await? + .output; + + let correction = records + .correct( + original.sequence_number, + Data::text("correct value"), + Some("approved".to_string()), + None, + ) + .build_and_execute(&client) + .await? + .output; + + assert_eq!(correction.trail_id, trail_id); + assert_eq!(correction.sequence_number, original.sequence_number + 1); + assert_eq!(correction.added_by, client.sender_address()); + + let old_record = records.get(original.sequence_number).await?; + assert_eq!(old_record.correction.replaces, HashSet::new()); + assert_eq!(old_record.correction.is_replaced_by, Some(correction.sequence_number)); + assert_text_data(old_record.data, "incorrect value"); + + let correction_record = records.get(correction.sequence_number).await?; + assert_eq!( + correction_record.correction.replaces, + HashSet::from([original.sequence_number]) + ); + assert_eq!(correction_record.correction.is_replaced_by, None); + assert_eq!(correction_record.metadata, Some("approved".to_string())); + assert_text_data(correction_record.data, "correct value"); + + let current = records.resolve_current(original.sequence_number).await?; + assert_eq!(current.sequence_number, correction.sequence_number); + assert_text_data(current.data, "correct value"); + + Ok(()) +} + +#[tokio::test] +async fn correct_record_not_found_fails() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client.create_test_trail(Data::text("correction-missing-root")).await?; + let records = client.trail(trail_id).records(); + + grant_role_capability(&client, trail_id, "RecordCorrector", [Permission::CorrectRecord]).await?; + + let denied = records + .correct(999, Data::text("correct value"), None, None) + .build_and_execute(&client) + .await; + + assert!(denied.is_err(), "correcting a missing sequence must fail"); + assert_eq!(records.record_count().await?, 1); + assert_text_data(records.get(0).await?.data, "correction-missing-root"); + + Ok(()) +} + +#[tokio::test] +async fn correct_record_requires_correct_record_permission() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client + .create_test_trail(Data::text("correction-permission-root")) + .await?; + let records = client.trail(trail_id).records(); + + grant_role_capability(&client, trail_id, "RecordWriter", [Permission::AddRecord]).await?; + + let denied = records + .correct(0, Data::text("correct value"), None, None) + .build_and_execute(&client) + .await; + + assert!(denied.is_err(), "correcting must require CorrectRecord permission"); + assert_eq!(records.record_count().await?, 1); + assert_text_data(records.get(0).await?.data, "correction-permission-root"); + + Ok(()) +} + +#[tokio::test] +async fn correct_record_rejects_replaced_record() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client.create_test_trail(Data::text("correction-replaced-root")).await?; + let records = client.trail(trail_id).records(); + + grant_role_capability(&client, trail_id, "RecordCorrector", [Permission::CorrectRecord]).await?; + + let first_correction = records + .correct(0, Data::text("first correction"), None, None) + .build_and_execute(&client) + .await? + .output; + + let denied = records + .correct(0, Data::text("second correction"), None, None) + .build_and_execute(&client) + .await; + + assert!(denied.is_err(), "correcting an already replaced record must fail"); + assert_eq!(records.record_count().await?, 2); + assert_eq!( + records.get(0).await?.correction.is_replaced_by, + Some(first_correction.sequence_number) + ); + assert_text_data(records.resolve_current(0).await?.data, "first correction"); + + Ok(()) +} + +#[tokio::test] +async fn correct_record_requires_matching_new_tag_access() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client + .create_test_trail_with_tags(Data::text("correction-tag-root"), ["finance", "legal"]) + .await?; + let records = client.trail(trail_id).records(); + + client + .create_role( + trail_id, + "FinanceCorrector", + [Permission::AddRecord, Permission::CorrectRecord], + Some(RoleTags::new(["finance"])), + ) + .await?; + client + .issue_cap(trail_id, "FinanceCorrector", CapabilityIssueOptions::default()) + .await?; + + let original = records + .add(Data::text("finance record"), None, Some("finance".to_string())) + .build_and_execute(&client) + .await? + .output; + + let denied = records + .correct( + original.sequence_number, + Data::text("legal correction"), + None, + Some("legal".to_string()), + ) + .build_and_execute(&client) + .await; + + assert!( + denied.is_err(), + "correction tag changes must require access to the new tag" + ); + assert_eq!(records.record_count().await?, 2); + let stored = records.get(original.sequence_number).await?; + assert_eq!(stored.correction.is_replaced_by, None); + assert_eq!(stored.tag.as_deref(), Some("finance")); + + Ok(()) +} + #[tokio::test] async fn add_record_selector_skips_revoked_capability_when_valid_one_exists() -> anyhow::Result<()> { let client = get_funded_test_client().await?; diff --git a/bindings/wasm/audit_trail_wasm/src/trail.rs b/bindings/wasm/audit_trail_wasm/src/trail.rs index 578f9339..6e240bd7 100644 --- a/bindings/wasm/audit_trail_wasm/src/trail.rs +++ b/bindings/wasm/audit_trail_wasm/src/trail.rs @@ -9,7 +9,7 @@ use audit_trails::core::create::{CreateTrail, TrailCreated}; use audit_trails::core::locking::{ UpdateDeleteRecordWindow, UpdateDeleteTrailLock, UpdateLockingConfig, UpdateWriteLock, }; -use audit_trails::core::records::{AddRecord, DeleteRecord, DeleteRecordsBatch}; +use audit_trails::core::records::{AddRecord, CorrectRecord, DeleteRecord, DeleteRecordsBatch}; use audit_trails::core::tags::{AddRecordTag, RemoveRecordTag}; use audit_trails::core::trail::{DeleteAuditTrail, Migrate, UpdateMetadata}; use audit_trails::core::types::{ @@ -1035,6 +1035,55 @@ impl WasmAddRecord { } } +/// Transaction wrapper for correcting a record. +/// +/// @remarks +/// Appends a new record that supersedes an existing record while preserving the original. Tagged +/// corrections require the supplied capability's role to allow both the replaced record's tag, when +/// present, and the correction record's tag, when present. +/// +/// Requires the {@link Permission.CorrectRecord} permission. +/// +/// Emits a {@link RecordAdded} event on success. +#[wasm_bindgen(js_name = CorrectRecord, inspectable)] +pub struct WasmCorrectRecord(pub(crate) CorrectRecord); + +#[wasm_bindgen(js_class = CorrectRecord)] +impl WasmCorrectRecord { + /// Builds the programmable transaction bytes for submission. + /// + /// @param client - Read-only core client used to resolve packages and serialize the + /// transaction. + /// + /// @returns BCS-encoded programmable transaction bytes ready for signing and submission. + /// + /// @throws When transaction serialization fails. + #[wasm_bindgen(js_name = buildProgrammableTransaction)] + pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { + build_programmable_transaction(&self.0, client).await + } + + /// Applies transaction effects and events and decodes the matching event payload. + /// + /// @param wasmEffects - Effects of the executed transaction. + /// @param wasmEvents - Events emitted by the executed transaction. + /// @param client - Read-only core client used during application. + /// + /// @returns Decoded {@link RecordAdded} event payload for the appended correction. + /// + /// @throws When the expected event is missing or transaction application fails. + #[wasm_bindgen(js_name = applyWithEvents)] + pub async fn apply_with_events( + self, + wasm_effects: &WasmIotaTransactionBlockEffects, + wasm_events: &WasmIotaTransactionBlockEvents, + client: &WasmCoreClientReadOnly, + ) -> Result { + let added: RecordAdded = apply_with_events(self.0, wasm_effects, wasm_events, client).await?; + Ok(added.into()) + } +} + /// Transaction wrapper for deleting a single record. /// /// @remarks diff --git a/bindings/wasm/audit_trail_wasm/src/trail_handle/records.rs b/bindings/wasm/audit_trail_wasm/src/trail_handle/records.rs index 8bb660fa..80c8aed2 100644 --- a/bindings/wasm/audit_trail_wasm/src/trail_handle/records.rs +++ b/bindings/wasm/audit_trail_wasm/src/trail_handle/records.rs @@ -11,8 +11,8 @@ use product_common::bindings::transaction::WasmTransactionBuilder; use product_common::bindings::utils::into_transaction_builder; use wasm_bindgen::prelude::*; -use crate::trail::{WasmAddRecord, WasmDeleteRecord, WasmDeleteRecordsBatch}; -use crate::types::{WasmData, WasmEmpty, WasmPaginatedRecord, WasmRecord}; +use crate::trail::{WasmAddRecord, WasmCorrectRecord, WasmDeleteRecord, WasmDeleteRecordsBatch}; +use crate::types::{WasmData, WasmPaginatedRecord, WasmRecord}; /// Record API scoped to a specific trail. /// @@ -144,25 +144,68 @@ impl WasmTrailRecords { Ok(page.into()) } - /// Executes the correction helper for a record payload. + /// Loads the current version of a record by following correction links. /// /// @remarks - /// Placeholder for a future correction helper — currently always throws because the underlying - /// implementation is not yet wired up. + /// Use {@link TrailRecords.get} when you need the exact immutable record stored at a sequence + /// number. Use `resolveCurrent` when you have an original sequence number and want the latest + /// correction in that record's replacement chain. /// - /// @param replaces - Sequence numbers of the records that the correction supersedes. - /// @param data - Replacement record payload. - /// @param metadata - Optional application-defined metadata stored alongside the correction. + /// For example, if record `3` was corrected by record `7`, and record `7` was later corrected + /// by record `9`, `resolveCurrent(3)` returns record `9`. If the starting record has not been + /// replaced, this returns the starting record itself. + /// + /// @param sequenceNumber - Sequence number to resolve. + /// + /// @returns The current record at the end of the correction chain. /// - /// @throws Always; the helper is not yet implemented. - pub async fn correct(&self, replaces: Vec, data: WasmData, metadata: Option) -> Result { - self.require_write()? + /// @throws When a record cannot be loaded or the replacement chain is malformed. + #[wasm_bindgen(js_name = resolveCurrent)] + pub async fn resolve_current(&self, sequence_number: u64) -> Result { + let record = self + .read_only .trail(self.trail_id) .records() - .correct(replaces, data.into(), metadata) + .resolve_current(sequence_number) .await .wasm_result()?; - Ok(WasmEmpty) + Ok(record.into()) + } + + /// Builds a record-correction transaction. + /// + /// @remarks + /// Appends a new correction record that supersedes `sequenceNumber` while preserving the + /// original record. When either the replaced record or the correction record carries a tag, the + /// supplied capability's role must allow that tag. + /// + /// Requires the {@link Permission.CorrectRecord} permission. + /// + /// @param sequenceNumber - Sequence number of the record to correct. + /// @param data - Replacement record payload. + /// @param metadata - Optional application-defined metadata stored alongside the correction. + /// @param tag - Optional trail-owned tag attached to the correction. + /// + /// @returns A {@link TransactionBuilder} wrapping the {@link CorrectRecord} transaction. + /// + /// @throws When the wrapper was created from a read-only client. + /// + /// Emits a {@link RecordAdded} event on success. + #[wasm_bindgen(unchecked_return_type = "TransactionBuilder")] + pub fn correct( + &self, + sequence_number: u64, + data: WasmData, + metadata: Option, + tag: Option, + ) -> Result { + let tx = self + .require_write()? + .trail(self.trail_id) + .records() + .correct(sequence_number, AuditTrailData::from(data), metadata, tag) + .into_inner(); + Ok(into_transaction_builder(WasmCorrectRecord(tx))) } /// Builds a record-add transaction. From 79e0d72eca132c271e2064dfec55c1453e8477db Mon Sep 17 00:00:00 2001 From: Yasir Date: Tue, 23 Jun 2026 20:38:29 +0300 Subject: [PATCH 2/9] feat: add example for correcting records in audit trail --- .../wasm/audit_trail_wasm/examples/README.md | 1 + .../src/advanced/12_correct_records.ts | 92 ++++++++++++++ .../audit_trail_wasm/examples/src/main.ts | 3 + .../audit_trail_wasm/examples/src/tests.ts | 4 + .../audit_trail_wasm/examples/src/web-main.ts | 3 + examples/Cargo.toml | 4 + examples/audit-trail/README.md | 1 + .../advanced/12_correct_records.rs | 118 ++++++++++++++++++ 8 files changed, 226 insertions(+) create mode 100644 bindings/wasm/audit_trail_wasm/examples/src/advanced/12_correct_records.ts create mode 100644 examples/audit-trail/advanced/12_correct_records.rs diff --git a/bindings/wasm/audit_trail_wasm/examples/README.md b/bindings/wasm/audit_trail_wasm/examples/README.md index 6d1b9f1a..1e6d016e 100644 --- a/bindings/wasm/audit_trail_wasm/examples/README.md +++ b/bindings/wasm/audit_trail_wasm/examples/README.md @@ -62,6 +62,7 @@ Available examples: | `09_tagged_records` | Uses role tags and address-bound capabilities to restrict who may add tagged records | | `10_capability_constraints` | Shows address-bound capability use and how revocation immediately blocks future writes | | `11_manage_record_tags` | Delegates tag management, adds/removes tags, shows that in-use tags cannot be removed | +| `12_correct_records` | Appends a correction record and resolves the current record from the original sequence | ### Real-World diff --git a/bindings/wasm/audit_trail_wasm/examples/src/advanced/12_correct_records.ts b/bindings/wasm/audit_trail_wasm/examples/src/advanced/12_correct_records.ts new file mode 100644 index 00000000..e26af8ec --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/examples/src/advanced/12_correct_records.ts @@ -0,0 +1,92 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +/** + * ## Actors + * + * - **Admin client**: Creates the trail, defines the RecordAdmin role, and issues a capability + * bound to `recordAdminClient`'s address. + * - **Record admin client**: Holds the capability. Appends a correction record, resolves the + * current record, and verifies that the original record cannot be corrected again. + * + * Demonstrates how to: + * 1. Append a correction record that supersedes an existing record. + * 2. Read the original and correction records directly. + * 3. Resolve the current record from the original sequence number. + * 4. Show that an already replaced record cannot be corrected again. + */ + +import { CapabilityIssueOptions, Data, PermissionSet } from "@iota/audit-trails/node"; +import { strict as assert } from "assert"; +import { getFundedClient, TEST_GAS_BUDGET } from "../util"; + +export async function correctRecords(): Promise { + console.log("=== Audit Trail Advanced: Correct Records ===\n"); + + const adminClient = await getFundedClient(); + const recordAdminClient = await getFundedClient(); + + const { output: createdTrail } = await adminClient + .createTrail() + .withInitialRecordString("Invoice total: 100 USD", "status:draft") + .finish() + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + + const trailId = createdTrail.id; + + const recordAdminRole = adminClient.trail(trailId).access().forRole("RecordAdmin"); + await recordAdminRole + .create(PermissionSet.recordAdminPermissions()) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + await recordAdminRole + .issueCapability(new CapabilityIssueOptions(recordAdminClient.senderAddress())) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + + const records = recordAdminClient.trail(trailId).records(); + + const correction = await records + .correct(0n, Data.fromString("Invoice total: 110 USD"), "status:corrected") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(recordAdminClient); + + console.log("Corrected record 0 by appending record", correction.output.sequenceNumber, "\n"); + + const original = await records.get(0n); + const correctionRecord = await records.get(correction.output.sequenceNumber); + const current = await records.resolveCurrent(0n); + + assert.equal( + original.correction.isReplacedBy, + correction.output.sequenceNumber, + "the original record must point to the correction", + ); + assert.ok( + correctionRecord.correction.replaces.includes(0n), + "the correction must reference the original sequence number", + ); + assert.equal( + current.sequenceNumber, + correction.output.sequenceNumber, + "resolveCurrent must return the appended correction", + ); + assert.equal(current.data.toString(), "Invoice total: 110 USD"); + + let secondCorrectionSucceeded = false; + try { + await records + .correct(0n, Data.fromString("Invoice total: 120 USD"), "status:second-correction") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(recordAdminClient); + secondCorrectionSucceeded = true; + } catch { + // Expected + } + assert.equal(secondCorrectionSucceeded, false, "an already replaced record must not be corrected again"); + + console.log("Original record:", original); + console.log("Correction record:", correctionRecord); + console.log("Current record resolved from #0:", current); +} diff --git a/bindings/wasm/audit_trail_wasm/examples/src/main.ts b/bindings/wasm/audit_trail_wasm/examples/src/main.ts index 3ec08c8e..3bf6c93f 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/main.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/main.ts @@ -12,6 +12,7 @@ import { deleteAuditTrail } from "./08_delete_audit_trail"; import { taggedRecords } from "./advanced/09_tagged_records"; import { capabilityConstraints } from "./advanced/10_capability_constraints"; import { manageRecordTags } from "./advanced/11_manage_record_tags"; +import { correctRecords } from "./advanced/12_correct_records"; import { customsClearance } from "./real-world/01_customs_clearance"; import { clinicalTrial } from "./real-world/02_clinical_trial"; import { digitalProductPassport } from "./real-world/03_digital_product_passport"; @@ -45,6 +46,8 @@ export async function main(example?: string) { return capabilityConstraints(); case "11_manage_record_tags": return manageRecordTags(); + case "12_correct_records": + return correctRecords(); case "01_customs_clearance": return customsClearance(); case "02_clinical_trial": diff --git a/bindings/wasm/audit_trail_wasm/examples/src/tests.ts b/bindings/wasm/audit_trail_wasm/examples/src/tests.ts index b7ba809a..aa8184cd 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/tests.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/tests.ts @@ -14,6 +14,7 @@ import { deleteAuditTrail } from "./08_delete_audit_trail"; import { taggedRecords } from "./advanced/09_tagged_records"; import { capabilityConstraints } from "./advanced/10_capability_constraints"; import { manageRecordTags } from "./advanced/11_manage_record_tags"; +import { correctRecords } from "./advanced/12_correct_records"; import { customsClearance } from "./real-world/01_customs_clearance"; import { clinicalTrial } from "./real-world/02_clinical_trial"; import { digitalProductPassport } from "./real-world/03_digital_product_passport"; @@ -56,6 +57,9 @@ describe("Audit trail wasm node examples", function() { it("manages record tags", async () => { await manageRecordTags(); }); + it("corrects records", async () => { + await correctRecords(); + }); it("runs customs clearance example", async () => { await customsClearance(); }); diff --git a/bindings/wasm/audit_trail_wasm/examples/src/web-main.ts b/bindings/wasm/audit_trail_wasm/examples/src/web-main.ts index 8f82711e..4485e94d 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/web-main.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/web-main.ts @@ -12,6 +12,7 @@ import { deleteAuditTrail } from "./08_delete_audit_trail"; import { taggedRecords } from "./advanced/09_tagged_records"; import { capabilityConstraints } from "./advanced/10_capability_constraints"; import { manageRecordTags } from "./advanced/11_manage_record_tags"; +import { correctRecords } from "./advanced/12_correct_records"; import { customsClearance } from "./real-world/01_customs_clearance"; import { clinicalTrial } from "./real-world/02_clinical_trial"; import { digitalProductPassport } from "./real-world/03_digital_product_passport"; @@ -45,6 +46,8 @@ export async function main(example?: string) { return capabilityConstraints(); case "11_manage_record_tags": return manageRecordTags(); + case "12_correct_records": + return correctRecords(); case "01_customs_clearance": return customsClearance(); case "02_clinical_trial": diff --git a/examples/Cargo.toml b/examples/Cargo.toml index f3c6da52..a9fde437 100644 --- a/examples/Cargo.toml +++ b/examples/Cargo.toml @@ -92,6 +92,10 @@ path = "audit-trail/advanced/10_capability_constraints.rs" name = "11_manage_record_tags" path = "audit-trail/advanced/11_manage_record_tags.rs" +[[example]] +name = "12_correct_records" +path = "audit-trail/advanced/12_correct_records.rs" + [[example]] name = "01_customs_clearance" path = "audit-trail/real-world/01_customs_clearance.rs" diff --git a/examples/audit-trail/README.md b/examples/audit-trail/README.md index e05495f8..57f67b6e 100644 --- a/examples/audit-trail/README.md +++ b/examples/audit-trail/README.md @@ -74,6 +74,7 @@ IOTA_AUDIT_TRAIL_PKG_ID=0x... IOTA_TF_COMPONENTS_PKG_ID=0x... cargo run --releas | [09_tagged_records](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/advanced/09_tagged_records.rs) | Uses role tags and address-bound capabilities to restrict who may add tagged records. | | [10_capability_constraints](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/advanced/10_capability_constraints.rs) | Shows address-bound capability use and how revocation immediately blocks future writes. | | [11_manage_record_tags](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/advanced/11_manage_record_tags.rs) | Delegates record-tag administration and shows that in-use tags cannot be removed. | +| [12_correct_records](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/advanced/12_correct_records.rs) | Appends a correction record and resolves the current record from the original sequence. | ## Real-World Examples diff --git a/examples/audit-trail/advanced/12_correct_records.rs b/examples/audit-trail/advanced/12_correct_records.rs new file mode 100644 index 00000000..8bb67189 --- /dev/null +++ b/examples/audit-trail/advanced/12_correct_records.rs @@ -0,0 +1,118 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! ## Actors +//! +//! - **Admin client**: Creates the trail, defines the RecordAdmin role, and issues a capability bound to +//! `record_admin_client`'s address. +//! - **Record admin client**: Holds the capability. Appends a correction record, resolves the current record, and +//! verifies that the original record cannot be corrected again. + +use anyhow::{Result, ensure}; +use audit_trails::core::types::{CapabilityIssueOptions, Data, InitialRecord, PermissionSet}; +use examples::get_funded_audit_trail_client; +use product_common::core_client::CoreClient; + +/// Demonstrates how to: +/// 1. Append a correction record that supersedes an existing record. +/// 2. Read the original and correction records directly. +/// 3. Resolve the current record from the original sequence number. +/// 4. Show that an already replaced record cannot be corrected again. +#[tokio::main] +async fn main() -> Result<()> { + println!("=== Audit Trail Advanced: Correct Records ===\n"); + + let admin_client = get_funded_audit_trail_client().await?; + let record_admin_client = get_funded_audit_trail_client().await?; + + let created_trail = admin_client + .create_trail() + .with_initial_record(InitialRecord::new( + Data::text("Invoice total: 100 USD"), + Some("status:draft".to_string()), + None, + )) + .finish()? + .build_and_execute(&admin_client) + .await? + .output; + + let trail_id = created_trail.trail_id; + let record_admin_role = "RecordAdmin"; + + admin_client + .trail(trail_id) + .access() + .for_role(record_admin_role) + .create(PermissionSet::record_admin_permissions(), None) + .build_and_execute(&admin_client) + .await?; + admin_client + .trail(trail_id) + .access() + .for_role(record_admin_role) + .issue_capability(CapabilityIssueOptions { + issued_to: Some(record_admin_client.sender_address()), + valid_from_ms: None, + valid_until_ms: None, + }) + .build_and_execute(&admin_client) + .await?; + + let records = record_admin_client.trail(trail_id).records(); + + let correction = records + .correct( + 0, + Data::text("Invoice total: 110 USD"), + Some("status:corrected".to_string()), + None, + ) + .build_and_execute(&record_admin_client) + .await? + .output; + + println!( + "Corrected record 0 by appending record {}.\n", + correction.sequence_number + ); + + let original = records.get(0).await?; + let correction_record = records.get(correction.sequence_number).await?; + let current = records.resolve_current(0).await?; + + ensure!( + original.correction.is_replaced_by == Some(correction.sequence_number), + "the original record must point to the correction" + ); + ensure!( + correction_record.correction.replaces.contains(&0), + "the correction must reference the original sequence number" + ); + ensure!( + current.sequence_number == correction.sequence_number, + "resolve_current must return the appended correction" + ); + ensure!(current.data == Data::text("Invoice total: 110 USD")); + + let second_correction_attempt = records + .correct( + 0, + Data::text("Invoice total: 120 USD"), + Some("status:second-correction".to_string()), + None, + ) + .build_and_execute(&record_admin_client) + .await; + + ensure!( + second_correction_attempt.is_err(), + "an already replaced record must not be corrected again" + ); + + println!("Original record: {:?}", original); + println!("Correction record: {:?}", correction_record); + println!("Current record resolved from #0: {:?}", current); + + Ok(()) +} From a97dc90388f74b3c11a630975bf1f3a2dd729a50 Mon Sep 17 00:00:00 2001 From: Yasir Date: Wed, 24 Jun 2026 13:18:25 +0300 Subject: [PATCH 3/9] feat: refactor record handling and improve timestamp retrieval --- audit-trail-move/sources/audit_trail.move | 2 +- audit-trail-move/tests/record_tests.move | 15 +++-- .../src/core/internal/linked_table.rs | 3 +- audit-trail-rs/src/core/records/operations.rs | 39 ++++------- .../src/core/records/transactions.rs | 10 +-- audit-trail-rs/src/core/types/record.rs | 66 +++++++++++++++++++ 6 files changed, 94 insertions(+), 41 deletions(-) diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index 39f302ba..6381c42e 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -598,7 +598,7 @@ public fun correct_record( assert!(is_record_tag_allowed(self, cap, &record_tag), ERecordTagNotAllowed); let caller = ctx.sender(); - let timestamp = clock::timestamp_ms(clock); + let timestamp = clock.timestamp_ms(); let trail_id = self.id(); let seq = self.sequence_number; diff --git a/audit-trail-move/tests/record_tests.move b/audit-trail-move/tests/record_tests.move index 03e193cb..bbb22d31 100644 --- a/audit-trail-move/tests/record_tests.move +++ b/audit-trail-move/tests/record_tests.move @@ -253,7 +253,10 @@ fun test_correct_record_appends_correction_and_links_records() { assert!(record::is_correction(record::correction(correction_record)), 4); let replaced_seq = 0; assert!( - vec_set::contains(record::replaces(record::correction(correction_record)), &replaced_seq), + vec_set::contains( + record::replaces(record::correction(correction_record)), + &replaced_seq, + ), 5, ); @@ -291,10 +294,12 @@ fun test_correct_record_can_use_different_allowed_tag() { &admin_cap, string::utf8(b"TaggedCorrector"), permission::record_admin_permissions(), - std::option::some(record_tags::new_role_tags(vector[ - string::utf8(b"finance"), - string::utf8(b"legal"), - ])), + std::option::some( + record_tags::new_role_tags(vector[ + string::utf8(b"finance"), + string::utf8(b"legal"), + ]), + ), &clock, ts::ctx(&mut scenario), ); diff --git a/audit-trail-rs/src/core/internal/linked_table.rs b/audit-trail-rs/src/core/internal/linked_table.rs index e8bd39f2..3b80e222 100644 --- a/audit-trail-rs/src/core/internal/linked_table.rs +++ b/audit-trail-rs/src/core/internal/linked_table.rs @@ -4,8 +4,7 @@ //! Helpers for reading Move `LinkedTable` nodes through dynamic fields. use iota_interaction::move_core_types::annotated_value::MoveValue; -use iota_interaction::rpc_types::IotaMoveValue; -use iota_interaction::rpc_types::{IotaData as _, IotaObjectDataOptions}; +use iota_interaction::rpc_types::{IotaData as _, IotaMoveValue, IotaObjectDataOptions}; use iota_interaction::types::collection_types::LinkedTableNode; use iota_interaction::types::dynamic_field::{DynamicFieldName, Field}; use iota_interaction::{IotaClientTrait, OptionalSync}; diff --git a/audit-trail-rs/src/core/records/operations.rs b/audit-trail-rs/src/core/records/operations.rs index 4ed8ca72..3790da8b 100644 --- a/audit-trail-rs/src/core/records/operations.rs +++ b/audit-trail-rs/src/core/records/operations.rs @@ -14,7 +14,7 @@ use product_common::core_client::CoreClientReadOnly; use crate::core::internal::capability::{find_capable_cap_for_tag, find_capable_cap_for_tags}; use crate::core::internal::{linked_table, trail as trail_reader, tx}; -use crate::core::types::{Data, Permission, Record}; +use crate::core::types::{Data, Permission, Record, RecordInput}; use crate::error::Error; /// Internal namespace for record-related transaction construction. @@ -29,16 +29,16 @@ impl RecordsOps { client: &C, trail_id: ObjectId, owner: IotaAddress, - data: Data, - record_metadata: Option, - record_tag: Option, + record: RecordInput, selected_capability_id: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, { let package_id = client.package_id(); - if let Some(tag) = record_tag.clone() { + let record_tag = record.tag.clone(); + + if let Some(tag) = record_tag { let trail = trail_reader::get_audit_trail(trail_id, client).await?; if !trail.tags.contains_key(&tag) { return Err(Error::InvalidArgument(format!( @@ -52,13 +52,9 @@ impl RecordsOps { }; tx::build_trail_transaction_with_cap_ref(client, trail_id, cap_ref, "add_record", |ptb, trail_tag| { - data.ensure_matches_tag(trail_tag, package_id)?; - - let data_arg = data.into_ptb(ptb, package_id)?; - let metadata = tx::ptb_pure(ptb, "record_metadata", record_metadata)?; - let tag_arg = tx::ptb_pure(ptb, "record_tag", Some(tag))?; + let [data, metadata, tag] = record.into_record_args(ptb, package_id, trail_tag)?; let clock = tx::get_clock_ref(ptb); - Ok(vec![data_arg, metadata, tag_arg, clock]) + Ok(vec![data, metadata, tag, clock]) }) .await } else { @@ -70,13 +66,9 @@ impl RecordsOps { selected_capability_id, "add_record", |ptb, trail_tag| { - data.ensure_matches_tag(trail_tag, package_id)?; - - let data_arg = data.into_ptb(ptb, package_id)?; - let metadata = tx::ptb_pure(ptb, "record_metadata", record_metadata)?; - let tag = tx::ptb_pure(ptb, "record_tag", Option::::None)?; + let [data, metadata, tag] = record.into_record_args(ptb, package_id, trail_tag)?; let clock = tx::get_clock_ref(ptb); - Ok(vec![data_arg, metadata, tag, clock]) + Ok(vec![data, metadata, tag, clock]) }, ) .await @@ -93,15 +85,14 @@ impl RecordsOps { trail_id: ObjectId, owner: IotaAddress, sequence_number: u64, - data: Data, - record_metadata: Option, - record_tag: Option, + record: RecordInput, selected_capability_id: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, { let package_id = client.package_id(); + let record_tag = record.tag.clone(); let trail = trail_reader::get_audit_trail(trail_id, client).await?; let replaced = linked_table::fetch_node_by_key::<_, Record>(client, trail.records.id, sequence_number) .await? @@ -140,14 +131,10 @@ impl RecordsOps { }; tx::build_trail_transaction_with_cap_ref(client, trail_id, cap_ref, "correct_record", |ptb, trail_tag| { - data.ensure_matches_tag(trail_tag, package_id)?; - let seq = tx::ptb_pure(ptb, "sequence_number", sequence_number)?; - let data_arg = data.into_ptb(ptb, package_id)?; - let metadata = tx::ptb_pure(ptb, "record_metadata", record_metadata)?; - let tag = tx::ptb_pure(ptb, "record_tag", record_tag)?; + let [data, metadata, tag] = record.into_record_args(ptb, package_id, trail_tag)?; let clock = tx::get_clock_ref(ptb); - Ok(vec![seq, data_arg, metadata, tag, clock]) + Ok(vec![seq, data, metadata, tag, clock]) }) .await } diff --git a/audit-trail-rs/src/core/records/transactions.rs b/audit-trail-rs/src/core/records/transactions.rs index b6723268..e96cf453 100644 --- a/audit-trail-rs/src/core/records/transactions.rs +++ b/audit-trail-rs/src/core/records/transactions.rs @@ -17,7 +17,7 @@ use product_common::transaction::transaction_builder::Transaction; use tokio::sync::OnceCell; use super::operations::RecordsOps; -use crate::core::types::{Data, Event, RecordAdded, RecordDeleted}; +use crate::core::types::{Data, Event, RecordAdded, RecordDeleted, RecordInput}; use crate::error::Error; // ===== AddRecord ===== @@ -76,9 +76,7 @@ impl AddRecord { client, self.trail_id, self.owner, - self.data.clone(), - self.metadata.clone(), - self.tag.clone(), + RecordInput::new(self.data.clone(), self.metadata.clone(), self.tag.clone()), self.selected_capability_id, ) .await @@ -185,9 +183,7 @@ impl CorrectRecord { self.trail_id, self.owner, self.sequence_number, - self.data.clone(), - self.metadata.clone(), - self.tag.clone(), + RecordInput::new(self.data.clone(), self.metadata.clone(), self.tag.clone()), self.selected_capability_id, ) .await diff --git a/audit-trail-rs/src/core/types/record.rs b/audit-trail-rs/src/core/types/record.rs index 7c5362a2..70e9f2a1 100644 --- a/audit-trail-rs/src/core/types/record.rs +++ b/audit-trail-rs/src/core/types/record.rs @@ -46,6 +46,72 @@ pub struct Record { pub correction: RecordCorrection, } +/// Input used when appending or correcting a record on an existing trail. +/// +/// This groups the record fields shared by add and correction operations. Trail creation keeps the +/// domain-specific [`InitialRecord`] type because it is encoded as Move's `record::InitialRecord`, +/// while existing-trail writes pass these fields directly to record entry functions. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RecordInput { + /// Payload to store in the trail. + pub data: D, + /// Optional application-defined metadata. + pub metadata: Option, + /// Optional tag from the trail-owned registry. + pub tag: Option, +} + +impl RecordInput { + /// Creates a new record input. + /// + /// # Examples + /// + /// ```rust + /// use audit_trails::core::types::{Data, RecordInput}; + /// + /// let record = RecordInput::new( + /// Data::text("hello"), + /// Some("metadata".to_string()), + /// Some("inbox".to_string()), + /// ); + /// + /// assert_eq!(record.data, Data::text("hello")); + /// assert_eq!(record.metadata.as_deref(), Some("metadata")); + /// assert_eq!(record.tag.as_deref(), Some("inbox")); + /// ``` + pub fn new(data: impl Into, metadata: Option, tag: Option) -> Self { + Self { + data: data.into(), + metadata, + tag, + } + } + + /// Converts this input into the Move call arguments used by record write operations. + /// + /// The returned arguments are ordered as `data`, `metadata`, and `tag`, matching the `add_record` and + /// `correct_record` entry point parameters after any operation-specific arguments. + /// + /// # Errors + /// + /// Returns an error if the input data type does not match the trail's configured record type, or if any field + /// cannot be serialized into a programmable transaction argument. + pub(in crate::core) fn into_record_args( + self, + ptb: &mut Ptb, + package_id: ObjectId, + trail_tag: &TypeTag, + ) -> Result<[Argument; 3], Error> { + self.data.ensure_matches_tag(trail_tag, package_id)?; + + let data = self.data.into_ptb(ptb, package_id)?; + let metadata = tx::ptb_pure(ptb, "record_metadata", self.metadata)?; + let tag = tx::ptb_pure(ptb, "record_tag", self.tag)?; + + Ok([data, metadata, tag]) + } +} + /// Input used when creating a trail with an initial record. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct InitialRecord { From 45a59446591f91ce1efb429b8612af65bfa7cbd5 Mon Sep 17 00:00:00 2001 From: Yasir Date: Wed, 24 Jun 2026 15:22:53 +0300 Subject: [PATCH 4/9] feat: update record correction API to use RecordInput struct for improved clarity --- audit-trail-rs/src/core/records/mod.rs | 16 +++++----------- audit-trail-rs/tests/e2e/records.rs | 19 ++++++++----------- .../src/trail_handle/records.rs | 7 +++++-- .../advanced/12_correct_records.rs | 18 +++++++++++------- 4 files changed, 29 insertions(+), 31 deletions(-) diff --git a/audit-trail-rs/src/core/records/mod.rs b/audit-trail-rs/src/core/records/mod.rs index 63f9707a..75d503e2 100644 --- a/audit-trail-rs/src/core/records/mod.rs +++ b/audit-trail-rs/src/core/records/mod.rs @@ -15,7 +15,7 @@ use serde::de::DeserializeOwned; use crate::core::internal::{linked_table, trail as trail_reader}; use crate::core::trail::{AuditTrailFull, AuditTrailReadOnly}; -use crate::core::types::{Data, PaginatedRecord, Record}; +use crate::core::types::{Data, PaginatedRecord, Record, RecordInput}; use crate::error::Error; mod operations; @@ -150,13 +150,7 @@ impl<'a, C, D> TrailRecords<'a, C, D> { /// The original record remains immutable. The correction is appended with a new sequence number and records /// that it supersedes `sequence_number`. Tagged corrections require a capability whose role allows both the /// replaced record's tag, when present, and the correction's tag, when present. - pub fn correct( - &self, - sequence_number: u64, - data: D, - metadata: Option, - tag: Option, - ) -> TransactionBuilder + pub fn correct(&self, sequence_number: u64, record: RecordInput) -> TransactionBuilder where C: AuditTrailFull + CoreClient, S: Signer + OptionalSync, @@ -167,9 +161,9 @@ impl<'a, C, D> TrailRecords<'a, C, D> { self.trail_id, owner, sequence_number, - data.into(), - metadata, - tag, + record.data.into(), + record.metadata, + record.tag, self.selected_capability_id, )) } diff --git a/audit-trail-rs/tests/e2e/records.rs b/audit-trail-rs/tests/e2e/records.rs index 80d2a32e..afdd1e89 100644 --- a/audit-trail-rs/tests/e2e/records.rs +++ b/audit-trail-rs/tests/e2e/records.rs @@ -5,7 +5,8 @@ use std::collections::HashSet; use std::time::{SystemTime, UNIX_EPOCH}; use audit_trails::core::types::{ - CapabilityIssueOptions, Data, InitialRecord, LockingConfig, LockingWindow, Permission, RoleTags, TimeLock, + CapabilityIssueOptions, Data, InitialRecord, LockingConfig, LockingWindow, Permission, RecordInput, RoleTags, + TimeLock, }; use audit_trails::error::Error; use iota_sdk_types::ObjectId; @@ -231,9 +232,7 @@ async fn correct_record_appends_correction_and_resolves_current_record() -> anyh let correction = records .correct( original.sequence_number, - Data::text("correct value"), - Some("approved".to_string()), - None, + RecordInput::new(Data::text("correct value"), Some("approved".to_string()), None), ) .build_and_execute(&client) .await? @@ -273,7 +272,7 @@ async fn correct_record_not_found_fails() -> anyhow::Result<()> { grant_role_capability(&client, trail_id, "RecordCorrector", [Permission::CorrectRecord]).await?; let denied = records - .correct(999, Data::text("correct value"), None, None) + .correct(999, RecordInput::new(Data::text("correct value"), None, None)) .build_and_execute(&client) .await; @@ -295,7 +294,7 @@ async fn correct_record_requires_correct_record_permission() -> anyhow::Result<( grant_role_capability(&client, trail_id, "RecordWriter", [Permission::AddRecord]).await?; let denied = records - .correct(0, Data::text("correct value"), None, None) + .correct(0, RecordInput::new(Data::text("correct value"), None, None)) .build_and_execute(&client) .await; @@ -315,13 +314,13 @@ async fn correct_record_rejects_replaced_record() -> anyhow::Result<()> { grant_role_capability(&client, trail_id, "RecordCorrector", [Permission::CorrectRecord]).await?; let first_correction = records - .correct(0, Data::text("first correction"), None, None) + .correct(0, RecordInput::new(Data::text("first correction"), None, None)) .build_and_execute(&client) .await? .output; let denied = records - .correct(0, Data::text("second correction"), None, None) + .correct(0, RecordInput::new(Data::text("second correction"), None, None)) .build_and_execute(&client) .await; @@ -365,9 +364,7 @@ async fn correct_record_requires_matching_new_tag_access() -> anyhow::Result<()> let denied = records .correct( original.sequence_number, - Data::text("legal correction"), - None, - Some("legal".to_string()), + RecordInput::new(Data::text("legal correction"), None, Some("legal".to_string())), ) .build_and_execute(&client) .await; diff --git a/bindings/wasm/audit_trail_wasm/src/trail_handle/records.rs b/bindings/wasm/audit_trail_wasm/src/trail_handle/records.rs index 7e654e76..6f5b627f 100644 --- a/bindings/wasm/audit_trail_wasm/src/trail_handle/records.rs +++ b/bindings/wasm/audit_trail_wasm/src/trail_handle/records.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use anyhow::anyhow; -use audit_trails::core::types::Data as AuditTrailData; +use audit_trails::core::types::{Data as AuditTrailData, RecordInput}; use audit_trails::{AuditTrailClient, AuditTrailClientReadOnly}; use iota_interaction_ts::bindings::WasmTransactionSigner; use iota_interaction_ts::wasm_error::{wasm_error, Result, WasmResult}; @@ -203,7 +203,10 @@ impl WasmTrailRecords { .require_write()? .trail(self.trail_id) .records() - .correct(sequence_number, AuditTrailData::from(data), metadata, tag) + .correct( + sequence_number, + RecordInput::new(AuditTrailData::from(data), metadata, tag), + ) .into_inner(); Ok(into_transaction_builder(WasmCorrectRecord(tx))) } diff --git a/examples/audit-trail/advanced/12_correct_records.rs b/examples/audit-trail/advanced/12_correct_records.rs index 8bb67189..e3ee9001 100644 --- a/examples/audit-trail/advanced/12_correct_records.rs +++ b/examples/audit-trail/advanced/12_correct_records.rs @@ -9,7 +9,7 @@ //! verifies that the original record cannot be corrected again. use anyhow::{Result, ensure}; -use audit_trails::core::types::{CapabilityIssueOptions, Data, InitialRecord, PermissionSet}; +use audit_trails::core::types::{CapabilityIssueOptions, Data, InitialRecord, PermissionSet, RecordInput}; use examples::get_funded_audit_trail_client; use product_common::core_client::CoreClient; @@ -64,9 +64,11 @@ async fn main() -> Result<()> { let correction = records .correct( 0, - Data::text("Invoice total: 110 USD"), - Some("status:corrected".to_string()), - None, + RecordInput::new( + Data::text("Invoice total: 110 USD"), + Some("status:corrected".to_string()), + None, + ), ) .build_and_execute(&record_admin_client) .await? @@ -98,9 +100,11 @@ async fn main() -> Result<()> { let second_correction_attempt = records .correct( 0, - Data::text("Invoice total: 120 USD"), - Some("status:second-correction".to_string()), - None, + RecordInput::new( + Data::text("Invoice total: 120 USD"), + Some("status:second-correction".to_string()), + None, + ), ) .build_and_execute(&record_admin_client) .await; From 79721976ecc9eb47af0d246c60bf885ff5364b47 Mon Sep 17 00:00:00 2001 From: Yasir Date: Wed, 24 Jun 2026 15:33:01 +0300 Subject: [PATCH 5/9] feat: streamline capability lookup by removing redundant function and updating record addition logic --- .../src/core/internal/capability.rs | 27 +++++-------------- audit-trail-rs/src/core/records/operations.rs | 7 ++--- 2 files changed, 10 insertions(+), 24 deletions(-) diff --git a/audit-trail-rs/src/core/internal/capability.rs b/audit-trail-rs/src/core/internal/capability.rs index e32f5100..c03253e1 100644 --- a/audit-trail-rs/src/core/internal/capability.rs +++ b/audit-trail-rs/src/core/internal/capability.rs @@ -181,39 +181,24 @@ where && cap.valid_until.is_none_or(|valid_until| now_ms <= valid_until) } -/// Finds an owned capability for adding a tagged record. -/// -/// Tagged writes have stricter lookup rules than ordinary permission-based -/// operations: the selected role must grant `AddRecord` and its configured -/// `RoleTags` must allow the requested record tag. -pub(crate) async fn find_capable_cap_for_tag( - client: &C, - owner: IotaAddress, - trail_id: ObjectId, - trail: &OnChainAuditTrail, - tag: &str, -) -> Result -where - C: CoreClientReadOnly + OptionalSync, -{ - find_capable_cap_for_tags(client, owner, trail_id, trail, Permission::AddRecord, &[tag]).await -} - /// Finds an owned capability for an operation that must satisfy tag-aware record authorization. /// /// Every tag in `tags` must be allowed by the capability's role. Empty tag lists fall back to ordinary /// permission-based capability discovery. -pub(crate) async fn find_capable_cap_for_tags( +pub(crate) async fn find_capable_cap_for_tags<'a, C, I>( client: &C, owner: IotaAddress, trail_id: ObjectId, trail: &OnChainAuditTrail, permission: Permission, - tags: &[&str], + tags: I, ) -> Result where C: CoreClientReadOnly + OptionalSync, + I: IntoIterator, { + let tags = tags.into_iter().collect::>(); + if tags.is_empty() { return find_capable_cap(client, owner, trail_id, trail, permission).await; } @@ -229,7 +214,7 @@ where .all(|tag| role.data.as_ref().is_some_and(|record_tags| record_tags.allows(tag))) }) .map(|(name, _)| name.clone()) - .collect::>(); + .collect::>(); let cap = find_owned_capability(client, owner, trail, |cap| { cap.target_key == trail_id && valid_roles.contains(&cap.role) diff --git a/audit-trail-rs/src/core/records/operations.rs b/audit-trail-rs/src/core/records/operations.rs index 3790da8b..267820da 100644 --- a/audit-trail-rs/src/core/records/operations.rs +++ b/audit-trail-rs/src/core/records/operations.rs @@ -12,7 +12,7 @@ use iota_interaction::types::transaction::ProgrammableTransaction; use iota_sdk_types::ObjectId; use product_common::core_client::CoreClientReadOnly; -use crate::core::internal::capability::{find_capable_cap_for_tag, find_capable_cap_for_tags}; +use crate::core::internal::capability::find_capable_cap_for_tags; use crate::core::internal::{linked_table, trail as trail_reader, tx}; use crate::core::types::{Data, Permission, Record, RecordInput}; use crate::error::Error; @@ -48,7 +48,8 @@ impl RecordsOps { let cap_ref = if let Some(capability_id) = selected_capability_id { tx::get_object_ref_by_id(client, &capability_id).await? } else { - find_capable_cap_for_tag(client, owner, trail_id, &trail, &tag).await? + find_capable_cap_for_tags(client, owner, trail_id, &trail, Permission::AddRecord, [tag.as_str()]) + .await? }; tx::build_trail_transaction_with_cap_ref(client, trail_id, cap_ref, "add_record", |ptb, trail_tag| { @@ -125,7 +126,7 @@ impl RecordsOps { trail_id, &trail, Permission::CorrectRecord, - &required_tags, + required_tags, ) .await? }; From 97c779ad2668fec1042f8c2eabeb3e1b8c1ad4f7 Mon Sep 17 00:00:00 2001 From: Yasir Date: Wed, 24 Jun 2026 16:55:57 +0300 Subject: [PATCH 6/9] feat: add correction record functionality and enhance related documentation --- audit-trail-move/api_mapping.toml | 13 ++++++++++++ audit-trail-rs/src/core/records/mod.rs | 10 ++++++++-- .../src/core/records/transactions.rs | 20 +++++++++++++------ bindings/wasm/audit_trail_wasm/src/trail.rs | 16 +++++++++++---- .../src/trail_handle/records.rs | 11 ++++++++-- 5 files changed, 56 insertions(+), 14 deletions(-) diff --git a/audit-trail-move/api_mapping.toml b/audit-trail-move/api_mapping.toml index 1cb31926..e7a49812 100644 --- a/audit-trail-move/api_mapping.toml +++ b/audit-trail-move/api_mapping.toml @@ -250,6 +250,19 @@ wasm = [ "WasmTrailRecords::add", ] +[audit_trails.main.correct_record] +rust = [ + "CorrectRecord", + "CorrectRecord::new", + "TrailRecords::correct", +] +wasm = [ + "WasmCorrectRecord", + "WasmCorrectRecord::build_programmable_transaction", + "WasmCorrectRecord::apply_with_events", + "WasmTrailRecords::correct", +] + [audit_trails.main.delete_record] rust = [ "DeleteRecord", diff --git a/audit-trail-rs/src/core/records/mod.rs b/audit-trail-rs/src/core/records/mod.rs index 75d503e2..f4c4b852 100644 --- a/audit-trail-rs/src/core/records/mod.rs +++ b/audit-trail-rs/src/core/records/mod.rs @@ -148,8 +148,14 @@ impl<'a, C, D> TrailRecords<'a, C, D> { /// Builds a transaction that appends a correction record to the trail. /// /// The original record remains immutable. The correction is appended with a new sequence number and records - /// that it supersedes `sequence_number`. Tagged corrections require a capability whose role allows both the - /// replaced record's tag, when present, and the correction's tag, when present. + /// that it supersedes `sequence_number`; the corrected record receives a back-pointer to the correction so + /// [`Self::resolve_current`] can follow the replacement chain. + /// + /// Requires `CorrectRecord`. Tagged corrections require the correction tag to exist in the trail registry + /// and the capability's role to allow both the replaced record's tag, when present, and the correction's + /// tag, when present. The transaction can fail if the trail package version is incompatible, the capability + /// is invalid, the trail is write-locked, the target record does not exist, the target record was already + /// replaced, or tag authorization fails. On success a `RecordAdded` event is emitted. pub fn correct(&self, sequence_number: u64, record: RecordInput) -> TransactionBuilder where C: AuditTrailFull + CoreClient, diff --git a/audit-trail-rs/src/core/records/transactions.rs b/audit-trail-rs/src/core/records/transactions.rs index e96cf453..2610e8dc 100644 --- a/audit-trail-rs/src/core/records/transactions.rs +++ b/audit-trail-rs/src/core/records/transactions.rs @@ -126,12 +126,16 @@ impl Transaction for AddRecord { /// Transaction that appends a correction record to a trail. /// -/// Requires the `CorrectRecord` permission. The new record supersedes `sequence_number` while preserving the -/// original record. Tagged corrections additionally require the tag to exist in the trail registry and the -/// capability's role to allow both the replaced record's tag, when present, and the new correction tag, when -/// present. The package also aborts with `ETrailWriteLocked` while the configured `write_lock` is active. On -/// success the correction is stored at the trail's current monotonic sequence number and a `RecordAdded` event -/// is emitted. +/// The original record remains immutable. The correction is appended at the trail's next sequence number with +/// a correction tracker whose `replaces` set contains the corrected sequence number, and the corrected record +/// receives a back-pointer to the new correction. +/// +/// Requires the `CorrectRecord` permission. Tagged corrections require the correction tag to exist in the trail +/// registry and the capability's role to allow both the replaced record's tag, when present, and the correction +/// tag, when present. The Move call aborts when the trail package version is incompatible, the capability is +/// invalid, the trail is write-locked, the target record does not exist, the target record was already replaced, +/// or tag authorization fails. On success the correction is stored at the trail's current monotonic sequence +/// number and a `RecordAdded` event is emitted. #[derive(Debug, Clone)] pub struct CorrectRecord { /// Trail object ID that will receive the correction. @@ -153,6 +157,10 @@ pub struct CorrectRecord { impl CorrectRecord { /// Creates a `CorrectRecord` transaction builder payload. + /// + /// The resulting transaction appends a correction record for `sequence_number` and carries the same + /// authorization, write-lock, record-existence, already-replaced, tag-definition, and tag-authorization + /// requirements as the Move `correct_record` entry point. pub fn new( trail_id: ObjectId, owner: IotaAddress, diff --git a/bindings/wasm/audit_trail_wasm/src/trail.rs b/bindings/wasm/audit_trail_wasm/src/trail.rs index 6e240bd7..7693bd33 100644 --- a/bindings/wasm/audit_trail_wasm/src/trail.rs +++ b/bindings/wasm/audit_trail_wasm/src/trail.rs @@ -1038,9 +1038,15 @@ impl WasmAddRecord { /// Transaction wrapper for correcting a record. /// /// @remarks -/// Appends a new record that supersedes an existing record while preserving the original. Tagged -/// corrections require the supplied capability's role to allow both the replaced record's tag, when -/// present, and the correction record's tag, when present. +/// Appends a new record that supersedes an existing record while preserving the original. The +/// correction records the sequence number it replaces, and the replaced record receives a +/// back-pointer to the new correction so clients can resolve the current record. +/// +/// Tagged corrections require the correction tag to exist in the trail registry and the supplied +/// capability's role to allow both the replaced record's tag, when present, and the correction +/// record's tag, when present. The transaction aborts on-chain when the package version is +/// incompatible, the capability is invalid, the trail is write-locked, the target record does not +/// exist, the target record was already replaced, or tag authorization fails. /// /// Requires the {@link Permission.CorrectRecord} permission. /// @@ -1057,7 +1063,9 @@ impl WasmCorrectRecord { /// /// @returns BCS-encoded programmable transaction bytes ready for signing and submission. /// - /// @throws When transaction serialization fails. + /// @throws When transaction construction or serialization fails, including when the target + /// record cannot be loaded, the correction tag is not defined, or no suitable capability can be + /// found. #[wasm_bindgen(js_name = buildProgrammableTransaction)] pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { build_programmable_transaction(&self.0, client).await diff --git a/bindings/wasm/audit_trail_wasm/src/trail_handle/records.rs b/bindings/wasm/audit_trail_wasm/src/trail_handle/records.rs index 6f5b627f..8920c332 100644 --- a/bindings/wasm/audit_trail_wasm/src/trail_handle/records.rs +++ b/bindings/wasm/audit_trail_wasm/src/trail_handle/records.rs @@ -176,8 +176,15 @@ impl WasmTrailRecords { /// /// @remarks /// Appends a new correction record that supersedes `sequenceNumber` while preserving the - /// original record. When either the replaced record or the correction record carries a tag, the - /// supplied capability's role must allow that tag. + /// original record. The correction records the sequence number it replaces, and the replaced + /// record receives a back-pointer to the new correction so `resolveCurrent` can follow the + /// replacement chain. + /// + /// Tagged corrections require the correction tag to exist in the trail registry and the + /// supplied capability's role to allow both the replaced record's tag, when present, and the + /// correction record's tag, when present. The transaction aborts on-chain when the package + /// version is incompatible, the capability is invalid, the trail is write-locked, the target + /// record does not exist, the target record was already replaced, or tag authorization fails. /// /// Requires the {@link Permission.CorrectRecord} permission. /// From 3b1b2604b3887f651594712905b28c3611a1462a Mon Sep 17 00:00:00 2001 From: Yasir Date: Thu, 25 Jun 2026 10:07:37 +0300 Subject: [PATCH 7/9] feat: update linked table node fetching to use new fetch_node function and clean up imports --- .../src/core/internal/linked_table.rs | 26 ++----------------- audit-trail-rs/src/core/records/mod.rs | 16 +++++++++--- audit-trail-rs/src/core/records/operations.rs | 19 +++++++++++--- 3 files changed, 30 insertions(+), 31 deletions(-) diff --git a/audit-trail-rs/src/core/internal/linked_table.rs b/audit-trail-rs/src/core/internal/linked_table.rs index 3b80e222..05c9cff5 100644 --- a/audit-trail-rs/src/core/internal/linked_table.rs +++ b/audit-trail-rs/src/core/internal/linked_table.rs @@ -3,12 +3,11 @@ //! Helpers for reading Move `LinkedTable` nodes through dynamic fields. -use iota_interaction::move_core_types::annotated_value::MoveValue; -use iota_interaction::rpc_types::{IotaData as _, IotaMoveValue, IotaObjectDataOptions}; +use iota_interaction::rpc_types::{IotaData as _, IotaObjectDataOptions}; use iota_interaction::types::collection_types::LinkedTableNode; use iota_interaction::types::dynamic_field::{DynamicFieldName, Field}; use iota_interaction::{IotaClientTrait, OptionalSync}; -use iota_sdk_types::{ObjectId, TypeTag}; +use iota_sdk_types::ObjectId; use product_common::core_client::CoreClientReadOnly; use serde::de::DeserializeOwned; @@ -64,24 +63,3 @@ where Ok(field.value) } - -/// Fetches and decodes a linked-table node keyed by a `u64`. -pub(crate) async fn fetch_node_by_key( - client: &C, - table_id: ObjectId, - key: u64, -) -> Result, Error> -where - C: CoreClientReadOnly + OptionalSync, - V: DeserializeOwned, -{ - fetch_node::<_, u64, V>( - client, - table_id, - DynamicFieldName { - type_: TypeTag::U64, - value: IotaMoveValue::from(MoveValue::U64(key)).to_json_value(), - }, - ) - .await -} diff --git a/audit-trail-rs/src/core/records/mod.rs b/audit-trail-rs/src/core/records/mod.rs index f4c4b852..4b8c4914 100644 --- a/audit-trail-rs/src/core/records/mod.rs +++ b/audit-trail-rs/src/core/records/mod.rs @@ -5,9 +5,12 @@ use std::collections::{BTreeMap, HashMap, HashSet}; +use iota_interaction::move_core_types::annotated_value::MoveValue; +use iota_interaction::rpc_types::IotaMoveValue; use iota_interaction::types::collection_types::LinkedTable; +use iota_interaction::types::dynamic_field::DynamicFieldName; use iota_interaction::{IotaKeySignature, OptionalSync}; -use iota_sdk_types::ObjectId; +use iota_sdk_types::{ObjectId, TypeTag}; use product_common::core_client::{CoreClient, CoreClientReadOnly}; use product_common::transaction::transaction_builder::TransactionBuilder; use secret_storage::Signer; @@ -17,7 +20,6 @@ use crate::core::internal::{linked_table, trail as trail_reader}; use crate::core::trail::{AuditTrailFull, AuditTrailReadOnly}; use crate::core::types::{Data, PaginatedRecord, Record, RecordInput}; use crate::error::Error; - mod operations; mod transactions; @@ -310,7 +312,15 @@ where ))); } - let node = linked_table::fetch_node_by_key::<_, V>(client, table.id, key).await?; + let node = linked_table::fetch_node::<_, u64, V>( + client, + table.id, + DynamicFieldName { + type_: TypeTag::U64, + value: IotaMoveValue::from(MoveValue::U64(key)).to_json_value(), + }, + ) + .await?; cursor = node.next; items.insert(key, node.value); diff --git a/audit-trail-rs/src/core/records/operations.rs b/audit-trail-rs/src/core/records/operations.rs index 267820da..05c8747a 100644 --- a/audit-trail-rs/src/core/records/operations.rs +++ b/audit-trail-rs/src/core/records/operations.rs @@ -7,9 +7,12 @@ //! arguments expected by the trail package. use iota_interaction::OptionalSync; +use iota_interaction::move_core_types::annotated_value::MoveValue; +use iota_interaction::rpc_types::IotaMoveValue; use iota_interaction::types::base_types::IotaAddress; +use iota_interaction::types::dynamic_field::DynamicFieldName; use iota_interaction::types::transaction::ProgrammableTransaction; -use iota_sdk_types::ObjectId; +use iota_sdk_types::{ObjectId, TypeTag}; use product_common::core_client::CoreClientReadOnly; use crate::core::internal::capability::find_capable_cap_for_tags; @@ -95,9 +98,17 @@ impl RecordsOps { let package_id = client.package_id(); let record_tag = record.tag.clone(); let trail = trail_reader::get_audit_trail(trail_id, client).await?; - let replaced = linked_table::fetch_node_by_key::<_, Record>(client, trail.records.id, sequence_number) - .await? - .value; + + let replaced = linked_table::fetch_node::<_, u64, Record>( + client, + trail.records.id, + DynamicFieldName { + type_: TypeTag::U64, + value: IotaMoveValue::from(MoveValue::U64(sequence_number)).to_json_value(), + }, + ) + .await? + .value; if let Some(tag) = record_tag.as_deref() { if !trail.tags.contains_key(tag) { From 308aedbeda0f48aa4424b794b84b2f99f48be6df Mon Sep 17 00:00:00 2001 From: Yasir Date: Thu, 25 Jun 2026 19:01:29 +0300 Subject: [PATCH 8/9] feat: simplify PhantomData usage in TrailRecords struct --- audit-trail-rs/src/core/records/mod.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/audit-trail-rs/src/core/records/mod.rs b/audit-trail-rs/src/core/records/mod.rs index 4b8c4914..1b627b1a 100644 --- a/audit-trail-rs/src/core/records/mod.rs +++ b/audit-trail-rs/src/core/records/mod.rs @@ -4,6 +4,7 @@ //! Record read and mutation APIs for Audit Trails. use std::collections::{BTreeMap, HashMap, HashSet}; +use std::marker::PhantomData; use iota_interaction::move_core_types::annotated_value::MoveValue; use iota_interaction::rpc_types::IotaMoveValue; @@ -37,7 +38,7 @@ pub struct TrailRecords<'a, C, D = Data> { pub(crate) client: &'a C, pub(crate) trail_id: ObjectId, pub(crate) selected_capability_id: Option, - pub(crate) _phantom: std::marker::PhantomData, + pub(crate) _phantom: PhantomData, } impl<'a, C, D> TrailRecords<'a, C, D> { @@ -46,7 +47,7 @@ impl<'a, C, D> TrailRecords<'a, C, D> { client, trail_id, selected_capability_id, - _phantom: std::marker::PhantomData, + _phantom: PhantomData, } } From 1ab34c7ba9c2fa5b68642d2d30973c2455ed1492 Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 29 Jun 2026 19:41:22 +0300 Subject: [PATCH 9/9] feat: refactor transaction apply methods to utilize new apply_with_events function --- audit-trail-move/sources/audit_trail.move | 13 +++---- .../src/core/access/transactions.rs | 37 ++++++++++--------- .../src/core/create/transactions.rs | 6 +-- audit-trail-rs/src/core/internal/tx.rs | 33 ++++++++++++++++- .../src/core/records/transactions.rs | 17 +++++---- audit-trail-rs/src/core/trail/transactions.rs | 5 ++- 6 files changed, 70 insertions(+), 41 deletions(-) diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index 6381c42e..feff4ffd 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -581,10 +581,10 @@ public fun correct_record( clock, ctx, ); - assert!(!locking::is_write_locked(&self.locking_config, clock), ETrailWriteLocked); - assert!(linked_table::contains(&self.records, sequence_number), ERecordNotFound); + assert!(!self.locking_config.is_write_locked(clock), ETrailWriteLocked); + assert!(self.records.contains(sequence_number), ERecordNotFound); assert!( - !record::is_replaced(record::correction(self.records.borrow(sequence_number))), + !self.records.borrow(sequence_number).correction().is_replaced(), ERecordAlreadyReplaced, ); assert!( @@ -603,7 +603,7 @@ public fun correct_record( let seq = self.sequence_number; if (record_tag.is_some()) { - record_tags::increment_usage_count(&mut self.tags, option::borrow(&record_tag)); + self.tags.increment_usage_count(record_tag.borrow()); }; let mut replaces = vec_set::empty(); @@ -619,10 +619,7 @@ public fun correct_record( record::with_replaces(replaces), ); - record::set_replaced_by( - record::correction_mut(linked_table::borrow_mut(&mut self.records, sequence_number)), - seq, - ); + self.records.borrow_mut(sequence_number).correction_mut().set_replaced_by(seq); linked_table::push_back(&mut self.records, seq, correction); self.sequence_number = self.sequence_number + 1; diff --git a/audit-trail-rs/src/core/access/transactions.rs b/audit-trail-rs/src/core/access/transactions.rs index 92113d54..29b15bbb 100644 --- a/audit-trail-rs/src/core/access/transactions.rs +++ b/audit-trail-rs/src/core/access/transactions.rs @@ -17,6 +17,7 @@ use product_common::transaction::transaction_builder::Transaction; use tokio::sync::OnceCell; use super::operations::AccessOps; +use crate::core::internal::tx; use crate::core::types::{ CapabilityDestroyed, CapabilityIssueOptions, CapabilityIssued, CapabilityRevoked, Event, PermissionSet, RawRoleCreated, RawRoleDeleted, RawRoleUpdated, RevokedCapabilitiesCleanedUp, RoleCreated, RoleDeleted, RoleTags, @@ -116,11 +117,11 @@ impl Transaction for CreateRole { Ok(event) } - async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + async fn apply(self, effects: &mut IotaTransactionBlockEffects, client: &C) -> Result where C: CoreClientReadOnly + OptionalSync, { - unreachable!("RoleCreated output requires transaction events") + tx::apply_with_events(self, effects, client).await } } @@ -212,11 +213,11 @@ impl Transaction for UpdateRole { Ok(event) } - async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + async fn apply(self, effects: &mut IotaTransactionBlockEffects, client: &C) -> Result where C: CoreClientReadOnly + OptionalSync, { - unreachable!() + tx::apply_with_events(self, effects, client).await } } @@ -294,11 +295,11 @@ impl Transaction for DeleteRole { Ok(event) } - async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + async fn apply(self, effects: &mut IotaTransactionBlockEffects, client: &C) -> Result where C: CoreClientReadOnly + OptionalSync, { - unreachable!() + tx::apply_with_events(self, effects, client).await } } @@ -385,11 +386,11 @@ impl Transaction for IssueCapability { Ok(event.data) } - async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + async fn apply(self, effects: &mut IotaTransactionBlockEffects, client: &C) -> Result where C: CoreClientReadOnly + OptionalSync, { - unreachable!() + tx::apply_with_events(self, effects, client).await } } @@ -476,11 +477,11 @@ impl Transaction for RevokeCapability { Ok(event.data) } - async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + async fn apply(self, effects: &mut IotaTransactionBlockEffects, client: &C) -> Result where C: CoreClientReadOnly + OptionalSync, { - unreachable!() + tx::apply_with_events(self, effects, client).await } } @@ -562,11 +563,11 @@ impl Transaction for DestroyCapability { Ok(event.data) } - async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + async fn apply(self, effects: &mut IotaTransactionBlockEffects, client: &C) -> Result where C: CoreClientReadOnly + OptionalSync, { - unreachable!() + tx::apply_with_events(self, effects, client).await } } @@ -638,11 +639,11 @@ impl Transaction for DestroyInitialAdminCapability { Ok(event.data) } - async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + async fn apply(self, effects: &mut IotaTransactionBlockEffects, client: &C) -> Result where C: CoreClientReadOnly + OptionalSync, { - unreachable!() + tx::apply_with_events(self, effects, client).await } } @@ -735,11 +736,11 @@ impl Transaction for RevokeInitialAdminCapability { Ok(event.data) } - async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + async fn apply(self, effects: &mut IotaTransactionBlockEffects, client: &C) -> Result where C: CoreClientReadOnly + OptionalSync, { - unreachable!() + tx::apply_with_events(self, effects, client).await } } @@ -814,10 +815,10 @@ impl Transaction for CleanupRevokedCapabilities { Ok(event.data) } - async fn apply(self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + async fn apply(self, effects: &mut IotaTransactionBlockEffects, client: &C) -> Result where C: CoreClientReadOnly + OptionalSync, { - unreachable!("RevokedCapabilitiesCleanedUp output requires transaction events") + tx::apply_with_events(self, effects, client).await } } diff --git a/audit-trail-rs/src/core/create/transactions.rs b/audit-trail-rs/src/core/create/transactions.rs index 3a59b288..f02b136d 100644 --- a/audit-trail-rs/src/core/create/transactions.rs +++ b/audit-trail-rs/src/core/create/transactions.rs @@ -13,7 +13,7 @@ use tokio::sync::OnceCell; use super::operations::{CreateOps, CreateTrailArgs}; use crate::core::builder::AuditTrailBuilder; -use crate::core::internal::trail as trail_reader; +use crate::core::internal::{trail as trail_reader, tx}; use crate::core::types::{AuditTrailCreated, Event, OnChainAuditTrail}; use crate::error::Error; @@ -138,10 +138,10 @@ impl Transaction for CreateTrail { }) } - async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + async fn apply(self, effects: &mut IotaTransactionBlockEffects, client: &C) -> Result where C: CoreClientReadOnly + OptionalSync, { - unreachable!() + tx::apply_with_events(self, effects, client).await } } diff --git a/audit-trail-rs/src/core/internal/tx.rs b/audit-trail-rs/src/core/internal/tx.rs index e7599e55..91d51758 100644 --- a/audit-trail-rs/src/core/internal/tx.rs +++ b/audit-trail-rs/src/core/internal/tx.rs @@ -5,22 +5,51 @@ use std::str::FromStr; -use iota_interaction::rpc_types::IotaObjectDataOptions; +use iota_interaction::rpc_types::{ + IotaObjectDataOptions, IotaTransactionBlockEffects, IotaTransactionBlockEffectsAPI, + IotaTransactionBlockResponseOptions, +}; use iota_interaction::types::base_types::{IotaAddress, ObjectRef}; use iota_interaction::types::programmable_transaction_builder::{ ProgrammableTransactionBuilder as Ptb, ProgrammableTransactionBuilder, }; use iota_interaction::types::transaction::{CallArg, ProgrammableTransaction, SharedObjectRef}; use iota_interaction::types::{IOTA_CLOCK_OBJECT_ID, IOTA_CLOCK_OBJECT_SHARED_VERSION, MOVE_STDLIB_PACKAGE_ID}; -use iota_interaction::{IotaClientTrait, OptionalSync, ident_str}; +use iota_interaction::{IotaClientTrait, OptionalSend, OptionalSync, ident_str}; use iota_sdk_types::{Argument, Identifier, ObjectId, Owner, TypeTag}; use product_common::core_client::CoreClientReadOnly; +use product_common::transaction::transaction_builder::Transaction; use serde::Serialize; use super::{capability, trail as trail_reader}; use crate::core::types::Permission; use crate::error::Error; +/// Applies a transaction whose output is decoded from events, fetching those events from the +/// transaction digest in `effects`. +pub(crate) async fn apply_with_events( + tx: T, + effects: &mut IotaTransactionBlockEffects, + client: &C, +) -> Result +where + T: Transaction + OptionalSend, + C: CoreClientReadOnly + OptionalSync, +{ + let response = client + .client_adapter() + .read_api() + .get_transaction_with_options( + *effects.transaction_digest(), + IotaTransactionBlockResponseOptions::full_content(), + ) + .await + .map_err(|err| Error::UnexpectedApiResponse(format!("failed to fetch transaction events; {err}")))?; + + let mut events = response.events().cloned().unwrap_or_default(); + tx.apply_with_events(effects, &mut events, client).await +} + /// Returns the canonical immutable clock object argument. pub(crate) fn get_clock_ref(ptb: &mut Ptb) -> Argument { ptb.obj(CallArg::Shared(SharedObjectRef { diff --git a/audit-trail-rs/src/core/records/transactions.rs b/audit-trail-rs/src/core/records/transactions.rs index 2610e8dc..22beed9d 100644 --- a/audit-trail-rs/src/core/records/transactions.rs +++ b/audit-trail-rs/src/core/records/transactions.rs @@ -17,6 +17,7 @@ use product_common::transaction::transaction_builder::Transaction; use tokio::sync::OnceCell; use super::operations::RecordsOps; +use crate::core::internal::tx; use crate::core::types::{Data, Event, RecordAdded, RecordDeleted, RecordInput}; use crate::error::Error; @@ -114,11 +115,11 @@ impl Transaction for AddRecord { Ok(event.data) } - async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + async fn apply(self, effects: &mut IotaTransactionBlockEffects, client: &C) -> Result where C: CoreClientReadOnly + OptionalSync, { - unreachable!() + tx::apply_with_events(self, effects, client).await } } @@ -229,11 +230,11 @@ impl Transaction for CorrectRecord { Ok(event.data) } - async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + async fn apply(self, effects: &mut IotaTransactionBlockEffects, client: &C) -> Result where C: CoreClientReadOnly + OptionalSync, { - unreachable!() + tx::apply_with_events(self, effects, client).await } } @@ -323,11 +324,11 @@ impl Transaction for DeleteRecord { Ok(event.data) } - async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + async fn apply(self, effects: &mut IotaTransactionBlockEffects, client: &C) -> Result where C: CoreClientReadOnly + OptionalSync, { - unreachable!() + tx::apply_with_events(self, effects, client).await } } @@ -422,10 +423,10 @@ impl Transaction for DeleteRecordsBatch { Ok(deleted) } - async fn apply(self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + async fn apply(self, effects: &mut IotaTransactionBlockEffects, client: &C) -> Result where C: CoreClientReadOnly + OptionalSync, { - unreachable!() + tx::apply_with_events(self, effects, client).await } } diff --git a/audit-trail-rs/src/core/trail/transactions.rs b/audit-trail-rs/src/core/trail/transactions.rs index 57f9a077..324d9feb 100644 --- a/audit-trail-rs/src/core/trail/transactions.rs +++ b/audit-trail-rs/src/core/trail/transactions.rs @@ -14,6 +14,7 @@ use product_common::transaction::transaction_builder::Transaction; use tokio::sync::OnceCell; use super::operations::TrailOps; +use crate::core::internal::tx; use crate::core::types::{AuditTrailDeleted, Event}; use crate::error::Error; @@ -205,10 +206,10 @@ impl Transaction for DeleteAuditTrail { Ok(event.data) } - async fn apply(self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + async fn apply(self, effects: &mut IotaTransactionBlockEffects, client: &C) -> Result where C: CoreClientReadOnly + OptionalSync, { - unreachable!() + tx::apply_with_events(self, effects, client).await } }