audit_trails/client/full_client.rs
1// Copyright 2020-2026 IOTA Stiftung
2// SPDX-License-Identifier: Apache-2.0
3
4//! # Audit Trails Client
5//!
6//! The full client extends [`AuditTrailClientReadOnly`] with signing support and write
7//! transaction builders.
8//!
9//! ## Transaction Flow
10//!
11//! Write APIs return a [`TransactionBuilder`](product_common::transaction::transaction_builder::TransactionBuilder)
12//! that you can configure before signing and submitting:
13//!
14//! ```rust,no_run
15//! # use audit_trails::AuditTrailClient;
16//! # use audit_trails::core::types::Data;
17//! # async fn example(
18//! # client: &AuditTrailClient<
19//! # impl secret_storage::Signer<iota_interaction::IotaKeySignature> + iota_interaction::OptionalSync,
20//! # >,
21//! # ) -> Result<(), Box<dyn std::error::Error>> {
22//! let created = client
23//! .create_trail()
24//! .with_initial_record_parts(Data::text("Initial record"), None, None)
25//! .finish()?
26//! .with_gas_budget(1_000_000)
27//! .build_and_execute(client)
28//! .await?;
29//!
30//! let trail_id = created.output.trail_id;
31//!
32//! client
33//! .trail(trail_id)
34//! .records()
35//! .add(Data::text("Follow-up record"), None, None)
36//! .build_and_execute(client)
37//! .await?;
38//! # Ok(())
39//! # }
40//! ```
41//!
42//! ## Example Workflow
43//!
44//! ```rust,no_run
45//! # use audit_trails::AuditTrailClient;
46//! # use audit_trails::core::types::{Data, PermissionSet, RoleTags};
47//! # async fn example(
48//! # client: &AuditTrailClient<
49//! # impl secret_storage::Signer<iota_interaction::IotaKeySignature> + iota_interaction::OptionalSync,
50//! # >,
51//! # ) -> Result<(), Box<dyn std::error::Error>> {
52//! let created = client
53//! .create_trail()
54//! .with_initial_record_parts(Data::text("Initial record"), None, None)
55//! .with_record_tags(["finance"])
56//! .finish()?
57//! .build_and_execute(client)
58//! .await?;
59//!
60//! let trail_id = created.output.trail_id;
61//!
62//! client
63//! .trail(trail_id)
64//! .access()
65//! .for_role("TaggedWriter")
66//! .create(PermissionSet::record_admin_permissions(), Some(RoleTags::new(["finance"])))
67//! .build_and_execute(client)
68//! .await?;
69//!
70//! client
71//! .trail(trail_id)
72//! .records()
73//! .add(Data::text("Budget approved"), None, Some("finance".to_string()))
74//! .build_and_execute(client)
75//! .await?;
76//! # Ok(())
77//! # }
78//! ```
79
80use std::ops::Deref;
81
82use async_trait::async_trait;
83#[cfg(not(target_arch = "wasm32"))]
84use iota_interaction::IotaClient;
85use iota_interaction::types::crypto::PublicKey;
86use iota_interaction::{IotaKeySignature, OptionalSync};
87#[cfg(target_arch = "wasm32")]
88use iota_interaction_ts::bindings::WasmIotaClient as IotaClient;
89use iota_sdk_types::{Address, ObjectId, ProgrammableTransaction};
90use product_common::core_client::{CoreClient, CoreClientReadOnly};
91use product_common::network_name::NetworkName;
92use secret_storage::Signer;
93use serde::de::DeserializeOwned;
94
95use crate::client::read_only::{AuditTrailClientReadOnly, PackageOverrides};
96use crate::core::builder::AuditTrailBuilder;
97use crate::core::trail::{AuditTrailFull, AuditTrailHandle, AuditTrailReadOnly};
98use crate::error::Error;
99use crate::iota_interaction_adapter::IotaClientAdapter;
100
101/// A marker type indicating the absence of a signer.
102#[derive(Debug, Clone, Copy)]
103#[non_exhaustive]
104pub struct NoSigner;
105
106/// The error that results from a failed attempt at creating an [`AuditTrailClient`]
107/// from a given [IotaClient].
108#[derive(Debug, thiserror::Error)]
109#[error("failed to create an 'AuditTrailClient' from the given 'IotaClient'")]
110#[non_exhaustive]
111pub struct FromIotaClientError {
112 /// Type of failure for this error.
113 #[source]
114 pub kind: FromIotaClientErrorKind,
115}
116
117/// Categories of failure for [`FromIotaClientError`].
118#[derive(Debug, thiserror::Error)]
119#[non_exhaustive]
120pub enum FromIotaClientErrorKind {
121 /// A package ID is required, but was not supplied.
122 #[error("an audit-trail package ID must be supplied when connecting to an unofficial IOTA network")]
123 MissingPackageId,
124 /// Network ID resolution through an RPC call failed.
125 #[error("failed to resolve the network the given client is connected to")]
126 NetworkResolution(#[source] Box<dyn std::error::Error + Send + Sync>),
127}
128
129/// A client for creating and managing audit trails on the IOTA blockchain.
130///
131/// This client combines read-only capabilities with transaction signing,
132/// enabling full interaction with audit trails.
133///
134/// ## Type Parameter
135///
136/// - `S`: The signer type that implements [`Signer<IotaKeySignature>`]
137#[derive(Clone)]
138pub struct AuditTrailClient<S> {
139 /// The underlying read-only client used for executing read-only operations.
140 pub(super) read_client: AuditTrailClientReadOnly,
141 /// The public key associated with the signer, if any.
142 pub(super) public_key: Option<PublicKey>,
143 /// The signer used for signing transactions, or `NoSigner` if the client is read-only.
144 pub(super) signer: S,
145}
146
147impl<S> Deref for AuditTrailClient<S> {
148 type Target = AuditTrailClientReadOnly;
149 fn deref(&self) -> &Self::Target {
150 &self.read_client
151 }
152}
153
154impl AuditTrailClient<NoSigner> {
155 /// Creates a new client with no signing capabilities from an IOTA client.
156 ///
157 /// # Warning
158 ///
159 /// Passing `package_overrides` is only needed when connecting to a custom IOTA network or
160 /// when testing against explicitly deployed package pairs.
161 ///
162 /// Relying on a custom audit-trail package while connected to an official IOTA network is
163 /// strongly discouraged and can lead to compatibility problems with other official IOTA Trust
164 /// Framework products.
165 ///
166 /// # Examples
167 /// ```rust,ignore
168 /// # use audit_trails::client::AuditTrailClient;
169 ///
170 /// # #[tokio::main]
171 /// # async fn main() -> anyhow::Result<()> {
172 /// let iota_client = iota_sdk::IotaClientBuilder::default()
173 /// .build_testnet()
174 /// .await?;
175 /// // No package ID is required since we are connecting to an official IOTA network.
176 /// let audit_trail_client = AuditTrailClient::from_iota_client(iota_client, None).await?;
177 /// # Ok(())
178 /// # }
179 /// ```
180 pub async fn from_iota_client(
181 iota_client: IotaClient,
182 package_overrides: impl Into<Option<PackageOverrides>>,
183 ) -> Result<Self, FromIotaClientError> {
184 let read_only_client = if let Some(package_overrides) = package_overrides.into() {
185 AuditTrailClientReadOnly::new_with_package_overrides(iota_client, package_overrides).await
186 } else {
187 AuditTrailClientReadOnly::new(iota_client).await
188 }
189 .map_err(|e| match e {
190 Error::InvalidConfig(_) => FromIotaClientErrorKind::MissingPackageId,
191 Error::RpcError(msg) => FromIotaClientErrorKind::NetworkResolution(msg.into()),
192 _ => unreachable!(
193 "'AuditTrailClientReadOnly::new' has been changed without updating error handling in 'AuditTrailClient::from_iota_client'"
194 ),
195 })
196 .map_err(|kind| FromIotaClientError { kind })?;
197
198 Ok(Self {
199 read_client: read_only_client,
200 public_key: None,
201 signer: NoSigner,
202 })
203 }
204}
205
206impl<S> AuditTrailClient<S> {
207 /// Creates a signing client from an existing read-only client and signer.
208 ///
209 /// # Errors
210 ///
211 /// Returns an error if the signer public key cannot be loaded.
212 pub async fn new(client: AuditTrailClientReadOnly, signer: S) -> Result<Self, Error>
213 where
214 S: Signer<IotaKeySignature>,
215 {
216 let public_key = signer
217 .public_key()
218 .await
219 .map_err(|e| Error::InvalidKey(e.to_string()))?;
220
221 Ok(AuditTrailClient {
222 read_client: client,
223 public_key: Some(public_key),
224 signer,
225 })
226 }
227
228 /// Replaces the signer used by this client.
229 ///
230 /// # Errors
231 ///
232 /// Returns an error if the replacement signer public key cannot be loaded.
233 pub async fn with_signer<NewS>(self, signer: NewS) -> Result<AuditTrailClient<NewS>, secret_storage::Error>
234 where
235 NewS: Signer<IotaKeySignature>,
236 {
237 let public_key = signer.public_key().await?;
238
239 Ok(AuditTrailClient {
240 read_client: self.read_client,
241 public_key: Some(public_key),
242 signer,
243 })
244 }
245 /// Returns the underlying read-only client view.
246 pub fn read_only(&self) -> &AuditTrailClientReadOnly {
247 &self.read_client
248 }
249
250 /// Returns a typed handle bound to a specific trail object ID.
251 pub fn trail<'a>(&'a self, trail_id: ObjectId) -> AuditTrailHandle<'a, Self> {
252 AuditTrailHandle::new(self, trail_id)
253 }
254
255 /// Returns the TfComponents package ID used by this client.
256 pub fn tf_components_package_id(&self) -> ObjectId {
257 self.read_client.tf_components_package_id()
258 }
259
260 /// Creates a builder for a new audit trail.
261 ///
262 /// When the client has a signer, the builder is pre-populated with that signer's address as
263 /// the initial admin.
264 pub fn create_trail(&self) -> AuditTrailBuilder {
265 AuditTrailBuilder {
266 admin: self.public_key.as_ref().map(Address::from),
267 ..AuditTrailBuilder::default()
268 }
269 }
270}
271
272impl<S> AuditTrailClient<S>
273where
274 S: Signer<IotaKeySignature>,
275{
276 /// Returns a reference to the [PublicKey] wrapped by this client.
277 pub fn public_key(&self) -> &PublicKey {
278 self.public_key.as_ref().expect("public_key is set")
279 }
280
281 /// Returns the [Address] wrapped by this client.
282 #[inline(always)]
283 pub fn address(&self) -> Address {
284 Address::from(self.public_key())
285 }
286}
287
288#[cfg_attr(feature = "send-sync", async_trait)]
289#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))]
290impl<S> CoreClientReadOnly for AuditTrailClient<S> {
291 fn package_id(&self) -> ObjectId {
292 self.read_client.package_id()
293 }
294
295 fn tf_components_package_id(&self) -> Option<ObjectId> {
296 Some(self.read_client.tf_components_package_id())
297 }
298
299 fn network_name(&self) -> &NetworkName {
300 self.read_client.network()
301 }
302
303 fn client_adapter(&self) -> &IotaClientAdapter {
304 &self.read_client
305 }
306}
307
308#[cfg_attr(feature = "send-sync", async_trait)]
309#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))]
310impl<S> CoreClient<S> for AuditTrailClient<S>
311where
312 S: Signer<IotaKeySignature> + OptionalSync,
313{
314 fn signer(&self) -> &S {
315 &self.signer
316 }
317
318 fn sender_address(&self) -> Address {
319 Address::from(self.public_key())
320 }
321
322 fn sender_public_key(&self) -> &PublicKey {
323 self.public_key()
324 }
325}
326
327#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))]
328#[cfg_attr(feature = "send-sync", async_trait)]
329impl<S> AuditTrailReadOnly for AuditTrailClient<S>
330where
331 S: Signer<IotaKeySignature> + OptionalSync,
332{
333 /// Delegates read-only execution to the wrapped [`AuditTrailClientReadOnly`].
334 async fn execute_read_only_transaction<T: DeserializeOwned>(
335 &self,
336 tx: ProgrammableTransaction,
337 ) -> Result<T, Error> {
338 self.read_client.execute_read_only_transaction(tx).await
339 }
340}
341
342impl<S> AuditTrailFull for AuditTrailClient<S> where S: Signer<IotaKeySignature> + OptionalSync {}