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::base_types::{IotaAddress, ObjectID};
86use iota_interaction::types::crypto::PublicKey;
87use iota_interaction::types::transaction::ProgrammableTransaction;
88use iota_interaction::{IotaKeySignature, OptionalSync};
89#[cfg(target_arch = "wasm32")]
90use iota_interaction_ts::bindings::WasmIotaClient as IotaClient;
91use product_common::core_client::{CoreClient, CoreClientReadOnly};
92use product_common::network_name::NetworkName;
93use secret_storage::Signer;
94use serde::de::DeserializeOwned;
95
96use crate::client::read_only::{AuditTrailClientReadOnly, PackageOverrides};
97use crate::core::builder::AuditTrailBuilder;
98use crate::core::trail::{AuditTrailFull, AuditTrailHandle, AuditTrailReadOnly};
99use crate::error::Error;
100use crate::iota_interaction_adapter::IotaClientAdapter;
101
102/// A marker type indicating the absence of a signer.
103#[derive(Debug, Clone, Copy)]
104#[non_exhaustive]
105pub struct NoSigner;
106
107/// The error that results from a failed attempt at creating an [`AuditTrailClient`]
108/// from a given [IotaClient].
109#[derive(Debug, thiserror::Error)]
110#[error("failed to create an 'AuditTrailClient' from the given 'IotaClient'")]
111#[non_exhaustive]
112pub struct FromIotaClientError {
113 /// Type of failure for this error.
114 #[source]
115 pub kind: FromIotaClientErrorKind,
116}
117
118/// Categories of failure for [`FromIotaClientError`].
119#[derive(Debug, thiserror::Error)]
120#[non_exhaustive]
121pub enum FromIotaClientErrorKind {
122 /// A package ID is required, but was not supplied.
123 #[error("an audit-trail package ID must be supplied when connecting to an unofficial IOTA network")]
124 MissingPackageId,
125 /// Network ID resolution through an RPC call failed.
126 #[error("failed to resolve the network the given client is connected to")]
127 NetworkResolution(#[source] Box<dyn std::error::Error + Send + Sync>),
128}
129
130/// A client for creating and managing audit trails on the IOTA blockchain.
131///
132/// This client combines read-only capabilities with transaction signing,
133/// enabling full interaction with audit trails.
134///
135/// ## Type Parameter
136///
137/// - `S`: The signer type that implements [`Signer<IotaKeySignature>`]
138#[derive(Clone)]
139pub struct AuditTrailClient<S> {
140 /// The underlying read-only client used for executing read-only operations.
141 pub(super) read_client: AuditTrailClientReadOnly,
142 /// The public key associated with the signer, if any.
143 pub(super) public_key: Option<PublicKey>,
144 /// The signer used for signing transactions, or `NoSigner` if the client is read-only.
145 pub(super) signer: S,
146}
147
148impl<S> Deref for AuditTrailClient<S> {
149 type Target = AuditTrailClientReadOnly;
150 fn deref(&self) -> &Self::Target {
151 &self.read_client
152 }
153}
154
155impl AuditTrailClient<NoSigner> {
156 /// Creates a new client with no signing capabilities from an IOTA client.
157 ///
158 /// # Warning
159 ///
160 /// Passing `package_overrides` is only needed when connecting to a custom IOTA network or
161 /// when testing against explicitly deployed package pairs.
162 ///
163 /// Relying on a custom audit-trail package while connected to an official IOTA network is
164 /// strongly discouraged and can lead to compatibility problems with other official IOTA Trust
165 /// Framework products.
166 ///
167 /// # Examples
168 /// ```rust,ignore
169 /// # use audit_trails::client::AuditTrailClient;
170 ///
171 /// # #[tokio::main]
172 /// # async fn main() -> anyhow::Result<()> {
173 /// let iota_client = iota_sdk::IotaClientBuilder::default()
174 /// .build_testnet()
175 /// .await?;
176 /// // No package ID is required since we are connecting to an official IOTA network.
177 /// let audit_trail_client = AuditTrailClient::from_iota_client(iota_client, None).await?;
178 /// # Ok(())
179 /// # }
180 /// ```
181 pub async fn from_iota_client(
182 iota_client: IotaClient,
183 package_overrides: impl Into<Option<PackageOverrides>>,
184 ) -> Result<Self, FromIotaClientError> {
185 let read_only_client = if let Some(package_overrides) = package_overrides.into() {
186 AuditTrailClientReadOnly::new_with_package_overrides(iota_client, package_overrides).await
187 } else {
188 AuditTrailClientReadOnly::new(iota_client).await
189 }
190 .map_err(|e| match e {
191 Error::InvalidConfig(_) => FromIotaClientErrorKind::MissingPackageId,
192 Error::RpcError(msg) => FromIotaClientErrorKind::NetworkResolution(msg.into()),
193 _ => unreachable!(
194 "'AuditTrailClientReadOnly::new' has been changed without updating error handling in 'AuditTrailClient::from_iota_client'"
195 ),
196 })
197 .map_err(|kind| FromIotaClientError { kind })?;
198
199 Ok(Self {
200 read_client: read_only_client,
201 public_key: None,
202 signer: NoSigner,
203 })
204 }
205}
206
207impl<S> AuditTrailClient<S> {
208 /// Creates a signing client from an existing read-only client and signer.
209 ///
210 /// # Errors
211 ///
212 /// Returns an error if the signer public key cannot be loaded.
213 pub async fn new(client: AuditTrailClientReadOnly, signer: S) -> Result<Self, Error>
214 where
215 S: Signer<IotaKeySignature>,
216 {
217 let public_key = signer
218 .public_key()
219 .await
220 .map_err(|e| Error::InvalidKey(e.to_string()))?;
221
222 Ok(AuditTrailClient {
223 read_client: client,
224 public_key: Some(public_key),
225 signer,
226 })
227 }
228
229 /// Replaces the signer used by this client.
230 ///
231 /// # Errors
232 ///
233 /// Returns an error if the replacement signer public key cannot be loaded.
234 pub async fn with_signer<NewS>(self, signer: NewS) -> Result<AuditTrailClient<NewS>, secret_storage::Error>
235 where
236 NewS: Signer<IotaKeySignature>,
237 {
238 let public_key = signer.public_key().await?;
239
240 Ok(AuditTrailClient {
241 read_client: self.read_client,
242 public_key: Some(public_key),
243 signer,
244 })
245 }
246 /// Returns the underlying read-only client view.
247 pub fn read_only(&self) -> &AuditTrailClientReadOnly {
248 &self.read_client
249 }
250
251 /// Returns a typed handle bound to a specific trail object ID.
252 pub fn trail<'a>(&'a self, trail_id: ObjectID) -> AuditTrailHandle<'a, Self> {
253 AuditTrailHandle::new(self, trail_id)
254 }
255
256 /// Returns the TfComponents package ID used by this client.
257 pub fn tf_components_package_id(&self) -> ObjectID {
258 self.read_client.tf_components_package_id()
259 }
260
261 /// Creates a builder for a new audit trail.
262 ///
263 /// When the client has a signer, the builder is pre-populated with that signer's address as
264 /// the initial admin.
265 pub fn create_trail(&self) -> AuditTrailBuilder {
266 AuditTrailBuilder {
267 admin: self.public_key.as_ref().map(IotaAddress::from),
268 ..AuditTrailBuilder::default()
269 }
270 }
271}
272
273impl<S> AuditTrailClient<S>
274where
275 S: Signer<IotaKeySignature>,
276{
277 /// Returns a reference to the [PublicKey] wrapped by this client.
278 pub fn public_key(&self) -> &PublicKey {
279 self.public_key.as_ref().expect("public_key is set")
280 }
281
282 /// Returns the [IotaAddress] wrapped by this client.
283 #[inline(always)]
284 pub fn address(&self) -> IotaAddress {
285 IotaAddress::from(self.public_key())
286 }
287}
288
289#[cfg_attr(feature = "send-sync", async_trait)]
290#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))]
291impl<S> CoreClientReadOnly for AuditTrailClient<S> {
292 fn package_id(&self) -> ObjectID {
293 self.read_client.package_id()
294 }
295
296 fn tf_components_package_id(&self) -> Option<ObjectID> {
297 Some(self.read_client.tf_components_package_id())
298 }
299
300 fn network_name(&self) -> &NetworkName {
301 self.read_client.network()
302 }
303
304 fn client_adapter(&self) -> &IotaClientAdapter {
305 &self.read_client
306 }
307}
308
309#[cfg_attr(feature = "send-sync", async_trait)]
310#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))]
311impl<S> CoreClient<S> for AuditTrailClient<S>
312where
313 S: Signer<IotaKeySignature> + OptionalSync,
314{
315 fn signer(&self) -> &S {
316 &self.signer
317 }
318
319 fn sender_address(&self) -> IotaAddress {
320 IotaAddress::from(self.public_key())
321 }
322
323 fn sender_public_key(&self) -> &PublicKey {
324 self.public_key()
325 }
326}
327
328#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))]
329#[cfg_attr(feature = "send-sync", async_trait)]
330impl<S> AuditTrailReadOnly for AuditTrailClient<S>
331where
332 S: Signer<IotaKeySignature> + OptionalSync,
333{
334 /// Delegates read-only execution to the wrapped [`AuditTrailClientReadOnly`].
335 async fn execute_read_only_transaction<T: DeserializeOwned>(
336 &self,
337 tx: ProgrammableTransaction,
338 ) -> Result<T, Error> {
339 self.read_client.execute_read_only_transaction(tx).await
340 }
341}
342
343impl<S> AuditTrailFull for AuditTrailClient<S> where S: Signer<IotaKeySignature> + OptionalSync {}