Skip to main content

miden_standards/note/
network_account_target.rs

1use alloc::vec::Vec;
2
3use miden_protocol::Word;
4use miden_protocol::account::AccountId;
5use miden_protocol::errors::{AccountIdError, NoteError};
6use miden_protocol::note::{NoteAttachment, NoteAttachmentScheme, NoteAttachments, NoteType};
7
8use crate::note::{NoteExecutionHint, StandardNoteAttachment};
9
10// NETWORK ACCOUNT TARGET
11// ================================================================================================
12
13/// A [`NoteAttachment`] for notes targeted at network accounts.
14///
15/// It can be encoded to and from a single-word attachment content with the following layout:
16///
17/// ```text
18/// - 0th felt: [target_id_suffix (56 bits) | 8 zero bits]
19/// - 1st felt: [target_id_prefix (64 bits)]
20/// - 2nd felt: [24 zero bits | exec_hint_payload (32 bits) | exec_hint_tag (8 bits)]
21/// - 3rd felt: [64 zero bits]
22/// ```
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub struct NetworkAccountTarget {
25    target_id: AccountId,
26    exec_hint: NoteExecutionHint,
27}
28
29impl NetworkAccountTarget {
30    // CONSTANTS
31    // --------------------------------------------------------------------------------------------
32
33    /// The standardized scheme of [`NetworkAccountTarget`] attachments.
34    pub const ATTACHMENT_SCHEME: NoteAttachmentScheme =
35        StandardNoteAttachment::NetworkAccountTarget.attachment_scheme();
36
37    // CONSTRUCTORS
38    // --------------------------------------------------------------------------------------------
39
40    /// Creates a new [`NetworkAccountTarget`] from the provided parts.
41    ///
42    /// # Errors
43    ///
44    /// Returns an error if:
45    /// - the provided `target_id` does not have
46    ///   [`AccountType::Public`](miden_protocol::account::AccountType::Public).
47    pub fn new(
48        target_id: AccountId,
49        exec_hint: NoteExecutionHint,
50    ) -> Result<Self, NetworkAccountTargetError> {
51        if !target_id.is_public() {
52            return Err(NetworkAccountTargetError::TargetNotPublic(target_id));
53        }
54
55        Ok(Self { target_id, exec_hint })
56    }
57
58    /// Ensures `attachments` carries a [`NetworkAccountTarget`] for `target_id`, appending one with
59    /// [`NoteExecutionHint::Always`] if none is present.
60    ///
61    /// This lets a note that is always targeted at a single network account derive its target from
62    /// that account, while leaving the caller free to supply the target themselves, e.g. to pick a
63    /// different execution hint, and to add any number of unrelated attachments in their own order.
64    ///
65    /// # Errors
66    ///
67    /// Returns an error if:
68    /// - an attachment with the [`NetworkAccountTarget::ATTACHMENT_SCHEME`] does not decode as a
69    ///   [`NetworkAccountTarget`] or targets an account other than `target_id`.
70    /// - no such attachment is present and `target_id` is not
71    ///   [`AccountType::Public`](miden_protocol::account::AccountType::Public), since a network
72    ///   account must be public.
73    pub(crate) fn ensure_presence(
74        attachments: &mut Vec<NoteAttachment>,
75        target_id: AccountId,
76    ) -> Result<(), NetworkAccountTargetError> {
77        // Every attachment of the scheme is validated, so no attachment can claim a target other
78        // than `target_id`.
79        let mut is_present = false;
80        for attachment in attachments
81            .iter()
82            .filter(|attachment| attachment.attachment_scheme() == Self::ATTACHMENT_SCHEME)
83        {
84            let attached_target_id = Self::try_from(attachment)?.target_id();
85            if attached_target_id != target_id {
86                return Err(NetworkAccountTargetError::TargetMismatch {
87                    expected: target_id,
88                    actual: attached_target_id,
89                });
90            }
91
92            is_present = true;
93        }
94
95        if !is_present {
96            let target = Self::new(target_id, NoteExecutionHint::Always)?;
97            attachments.push(NoteAttachment::from(target));
98        }
99
100        Ok(())
101    }
102
103    // ACCESSORS
104    // --------------------------------------------------------------------------------------------
105
106    /// Returns the [`AccountId`] at which the note is targeted.
107    pub fn target_id(&self) -> AccountId {
108        self.target_id
109    }
110
111    /// Returns the [`NoteExecutionHint`] of the note.
112    pub fn execution_hint(&self) -> NoteExecutionHint {
113        self.exec_hint
114    }
115}
116
117impl From<NetworkAccountTarget> for NoteAttachment {
118    fn from(network_attachment: NetworkAccountTarget) -> Self {
119        let mut word = Word::empty();
120        word[0] = network_attachment.target_id.suffix();
121        word[1] = network_attachment.target_id.prefix().as_felt();
122        word[2] = network_attachment.exec_hint.into();
123
124        NoteAttachment::with_word(NetworkAccountTarget::ATTACHMENT_SCHEME, word)
125    }
126}
127
128impl TryFrom<&NoteAttachments> for NetworkAccountTarget {
129    type Error = NetworkAccountTargetError;
130
131    fn try_from(attachments: &NoteAttachments) -> Result<Self, Self::Error> {
132        // Find the first matching attachment. In case of multiple network account target
133        // attachments, we pick the first one as the canonical one.
134        let attachment = attachments
135            .find(NetworkAccountTarget::ATTACHMENT_SCHEME)
136            .ok_or_else(|| NetworkAccountTargetError::MissingAttachmentScheme)?;
137
138        Self::try_from(attachment)
139    }
140}
141impl TryFrom<&NoteAttachment> for NetworkAccountTarget {
142    type Error = NetworkAccountTargetError;
143
144    fn try_from(attachment: &NoteAttachment) -> Result<Self, Self::Error> {
145        if attachment.attachment_scheme() != Self::ATTACHMENT_SCHEME {
146            return Err(NetworkAccountTargetError::AttachmentSchemeMismatch(
147                attachment.attachment_scheme(),
148            ));
149        }
150
151        let words = attachment.content().as_words();
152        if words.len() != 1 {
153            return Err(NetworkAccountTargetError::AttachmentContentNumWordsMismatch(
154                attachment.content().num_words(),
155            ));
156        }
157        let word = words[0];
158
159        let id_suffix = word[0];
160        let id_prefix = word[1];
161        let exec_hint = word[2];
162
163        let target_id = AccountId::try_from_elements(id_suffix, id_prefix)
164            .map_err(NetworkAccountTargetError::DecodeTargetId)?;
165
166        let exec_hint = NoteExecutionHint::try_from(exec_hint.as_canonical_u64())
167            .map_err(NetworkAccountTargetError::DecodeExecutionHint)?;
168
169        NetworkAccountTarget::new(target_id, exec_hint)
170    }
171}
172
173// NETWORK ACCOUNT TARGET ERROR
174// ================================================================================================
175
176#[derive(Debug, thiserror::Error)]
177pub enum NetworkAccountTargetError {
178    #[error("note attachments do not contain a network account target scheme")]
179    MissingAttachmentScheme,
180    #[error("target account ID must have public account type")]
181    TargetNotPublic(AccountId),
182    #[error("attached network account target {actual} does not match expected target {expected}")]
183    TargetMismatch { expected: AccountId, actual: AccountId },
184    #[error(
185        "attachment scheme {0} did not match expected type {expected}",
186        expected = NetworkAccountTarget::ATTACHMENT_SCHEME
187    )]
188    AttachmentSchemeMismatch(NoteAttachmentScheme),
189    #[error("network account target expects attachment content with one word, got {0}")]
190    AttachmentContentNumWordsMismatch(u16),
191    #[error("failed to decode target account ID")]
192    DecodeTargetId(#[source] AccountIdError),
193    #[error("failed to decode execution hint")]
194    DecodeExecutionHint(#[source] NoteError),
195    #[error("network note must be public, but was {0:?}")]
196    NoteNotPublic(NoteType),
197}
198
199// TESTS
200// ================================================================================================
201
202#[cfg(test)]
203mod tests {
204    use alloc::vec;
205
206    use assert_matches::assert_matches;
207    use miden_protocol::account::AccountType;
208    use miden_protocol::testing::account_id::AccountIdBuilder;
209
210    use super::*;
211
212    fn public_account_id() -> AccountId {
213        AccountIdBuilder::new()
214            .account_type(AccountType::Public)
215            .build_with_rng(&mut rand::rng())
216    }
217
218    #[test]
219    fn network_account_target_serde() -> anyhow::Result<()> {
220        let id = public_account_id();
221        let network_account_target = NetworkAccountTarget::new(id, NoteExecutionHint::Always)?;
222        assert_eq!(
223            network_account_target,
224            NetworkAccountTarget::try_from(&NoteAttachment::from(network_account_target))?
225        );
226
227        Ok(())
228    }
229
230    /// A caller-supplied target for the same account is kept as-is, so its execution hint survives
231    /// and no duplicate attachment is added.
232    #[test]
233    fn ensure_presence_keeps_matching_target() -> anyhow::Result<()> {
234        let target_id = public_account_id();
235        let supplied = NetworkAccountTarget::new(target_id, NoteExecutionHint::None)?;
236        let mut attachments = vec![NoteAttachment::from(supplied)];
237
238        NetworkAccountTarget::ensure_presence(&mut attachments, target_id)?;
239
240        assert_eq!(attachments.len(), 1);
241        assert_eq!(NetworkAccountTarget::try_from(&attachments[0])?, supplied);
242
243        Ok(())
244    }
245
246    /// A caller-supplied target for another account is rejected instead of being silently
247    /// shadowed by the note's own target.
248    #[test]
249    fn ensure_presence_rejects_mismatched_target() -> anyhow::Result<()> {
250        let target_id = public_account_id();
251        let other_id = public_account_id();
252        let supplied = NetworkAccountTarget::new(other_id, NoteExecutionHint::Always)?;
253        let mut attachments = vec![NoteAttachment::from(supplied)];
254
255        let err = NetworkAccountTarget::ensure_presence(&mut attachments, target_id).unwrap_err();
256
257        assert_matches!(
258            err,
259            NetworkAccountTargetError::TargetMismatch { expected, actual }
260                if expected == target_id && actual == other_id
261        );
262
263        Ok(())
264    }
265
266    /// The appended target is placed after the caller's attachments, leaving their order intact.
267    #[test]
268    fn ensure_presence_appends_missing_target() -> anyhow::Result<()> {
269        let target_id = public_account_id();
270        let unrelated =
271            NoteAttachment::with_word(NoteAttachmentScheme::new(64)?, Word::from([7u32, 0, 0, 0]));
272        let mut attachments = vec![unrelated.clone()];
273
274        NetworkAccountTarget::ensure_presence(&mut attachments, target_id)?;
275
276        assert_eq!(
277            attachments,
278            vec![
279                unrelated,
280                NoteAttachment::from(NetworkAccountTarget::new(
281                    target_id,
282                    NoteExecutionHint::Always
283                )?)
284            ]
285        );
286
287        Ok(())
288    }
289
290    #[test]
291    fn network_account_target_fails_on_private_target_account() -> anyhow::Result<()> {
292        let id = AccountIdBuilder::new()
293            .account_type(AccountType::Private)
294            .build_with_rng(&mut rand::rng());
295        let err = NetworkAccountTarget::new(id, NoteExecutionHint::Always).unwrap_err();
296
297        assert_matches!(
298            err,
299            NetworkAccountTargetError::TargetNotPublic(account_id) if account_id == id
300        );
301
302        Ok(())
303    }
304}