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;
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        if !Self::validate_target(attachments, target_id)? {
78            let target = Self::new(target_id, NoteExecutionHint::Always)?;
79            attachments.push(NoteAttachment::from(target));
80        }
81
82        Ok(())
83    }
84
85    /// Behaves like [`Self::ensure_presence`], except that a non-public `target_id` is accepted
86    /// without appending a target.
87    ///
88    /// A private account is never a network account, so it has no routing target to derive. This
89    /// lets a note whose target may be either kind of account carry the target exactly when it is
90    /// meaningful, while a caller-supplied target for another account is rejected either way.
91    ///
92    /// # Errors
93    ///
94    /// Returns an error if an attachment with the [`NetworkAccountTarget::ATTACHMENT_SCHEME`] does
95    /// not decode as a [`NetworkAccountTarget`] or targets an account other than `target_id`.
96    pub(crate) fn ensure_presence_if_public(
97        attachments: &mut Vec<NoteAttachment>,
98        target_id: AccountId,
99    ) -> Result<(), NetworkAccountTargetError> {
100        if target_id.is_public() {
101            return Self::ensure_presence(attachments, target_id);
102        }
103
104        // No target is derived, but any attachment the caller supplied under the scheme is still
105        // validated against `target_id`.
106        Self::validate_target(attachments, target_id).map(|_| ())
107    }
108
109    /// Validates every attachment carrying the [`NetworkAccountTarget::ATTACHMENT_SCHEME`]
110    /// against `target_id`, returning whether one of them is present.
111    ///
112    /// # Errors
113    ///
114    /// Returns an error if such an attachment does not decode as a [`NetworkAccountTarget`], which
115    /// is the case for one naming a non-public account, or targets an account other than
116    /// `target_id`.
117    fn validate_target(
118        attachments: &[NoteAttachment],
119        target_id: AccountId,
120    ) -> Result<bool, NetworkAccountTargetError> {
121        let mut is_present = false;
122        for attachment in attachments
123            .iter()
124            .filter(|attachment| attachment.attachment_scheme() == Self::ATTACHMENT_SCHEME)
125        {
126            let attached_target_id = Self::try_from(attachment)?.target_id();
127            if attached_target_id != target_id {
128                return Err(NetworkAccountTargetError::TargetMismatch {
129                    expected: target_id,
130                    actual: attached_target_id,
131                });
132            }
133
134            is_present = true;
135        }
136
137        Ok(is_present)
138    }
139
140    // ACCESSORS
141    // --------------------------------------------------------------------------------------------
142
143    /// Returns the [`AccountId`] at which the note is targeted.
144    pub fn target_id(&self) -> AccountId {
145        self.target_id
146    }
147
148    /// Returns the [`NoteExecutionHint`] of the note.
149    pub fn execution_hint(&self) -> NoteExecutionHint {
150        self.exec_hint
151    }
152}
153
154impl From<NetworkAccountTarget> for NoteAttachment {
155    fn from(network_attachment: NetworkAccountTarget) -> Self {
156        let mut word = Word::empty();
157        word[0] = network_attachment.target_id.suffix();
158        word[1] = network_attachment.target_id.prefix().as_felt();
159        word[2] = network_attachment.exec_hint.into();
160
161        NoteAttachment::with_word(NetworkAccountTarget::ATTACHMENT_SCHEME, word)
162    }
163}
164
165impl TryFrom<&NoteAttachments> for NetworkAccountTarget {
166    type Error = NetworkAccountTargetError;
167
168    fn try_from(attachments: &NoteAttachments) -> Result<Self, Self::Error> {
169        // Find the first matching attachment. In case of multiple network account target
170        // attachments, we pick the first one as the canonical one.
171        let attachment = attachments
172            .find(NetworkAccountTarget::ATTACHMENT_SCHEME)
173            .ok_or_else(|| NetworkAccountTargetError::MissingAttachmentScheme)?;
174
175        Self::try_from(attachment)
176    }
177}
178impl TryFrom<&NoteAttachment> for NetworkAccountTarget {
179    type Error = NetworkAccountTargetError;
180
181    fn try_from(attachment: &NoteAttachment) -> Result<Self, Self::Error> {
182        if attachment.attachment_scheme() != Self::ATTACHMENT_SCHEME {
183            return Err(NetworkAccountTargetError::AttachmentSchemeMismatch(
184                attachment.attachment_scheme(),
185            ));
186        }
187
188        let words = attachment.content().as_words();
189        if words.len() != 1 {
190            return Err(NetworkAccountTargetError::AttachmentContentNumWordsMismatch(
191                attachment.content().num_words(),
192            ));
193        }
194        let word = words[0];
195
196        let id_suffix = word[0];
197        let id_prefix = word[1];
198        let exec_hint = word[2];
199
200        let target_id = AccountId::try_from_elements(id_suffix, id_prefix)
201            .map_err(NetworkAccountTargetError::DecodeTargetId)?;
202
203        NetworkAccountTarget::new(target_id, NoteExecutionHint::from(exec_hint))
204    }
205}
206
207// NETWORK ACCOUNT TARGET ERROR
208// ================================================================================================
209
210#[derive(Debug, thiserror::Error)]
211pub enum NetworkAccountTargetError {
212    #[error("note attachments do not contain a network account target scheme")]
213    MissingAttachmentScheme,
214    #[error("target account ID must have public account type")]
215    TargetNotPublic(AccountId),
216    #[error("attached network account target {actual} does not match expected target {expected}")]
217    TargetMismatch { expected: AccountId, actual: AccountId },
218    #[error(
219        "attachment scheme {0} did not match expected type {expected}",
220        expected = NetworkAccountTarget::ATTACHMENT_SCHEME
221    )]
222    AttachmentSchemeMismatch(NoteAttachmentScheme),
223    #[error("network account target expects attachment content with one word, got {0}")]
224    AttachmentContentNumWordsMismatch(u16),
225    #[error("failed to decode target account ID")]
226    DecodeTargetId(#[source] AccountIdError),
227    #[error("network note must be public, but was {0:?}")]
228    NoteNotPublic(NoteType),
229}
230
231// TESTS
232// ================================================================================================
233
234#[cfg(test)]
235mod tests {
236    use alloc::vec;
237
238    use assert_matches::assert_matches;
239    use miden_protocol::Felt;
240    use miden_protocol::account::AccountType;
241    use miden_protocol::testing::account_id::AccountIdBuilder;
242
243    use super::*;
244
245    fn public_account_id() -> AccountId {
246        AccountIdBuilder::new()
247            .account_type(AccountType::Public)
248            .build_with_rng(&mut rand::rng())
249    }
250
251    #[test]
252    fn network_account_target_serde() -> anyhow::Result<()> {
253        let id = public_account_id();
254        let network_account_target = NetworkAccountTarget::new(id, NoteExecutionHint::Always)?;
255        assert_eq!(
256            network_account_target,
257            NetworkAccountTarget::try_from(&NoteAttachment::from(network_account_target))?
258        );
259
260        Ok(())
261    }
262
263    /// An execution hint encoding this version does not recognize must not hide the target
264    /// account, since the on-chain check discards the hint felt entirely.
265    #[test]
266    fn unrecognized_execution_hint_preserves_target_id() -> anyhow::Result<()> {
267        let target_id = public_account_id();
268
269        // Tag 7 is above the highest known tag, and a non-zero payload on the `Always` tag is
270        // rejected by `NoteExecutionHint::from_parts`.
271        for raw_hint in [7u64, (1 << 8) | 1] {
272            let raw_hint = Felt::new(raw_hint)?;
273            let mut word = Word::empty();
274            word[0] = target_id.suffix();
275            word[1] = target_id.prefix().as_felt();
276            word[2] = raw_hint;
277            let attachment =
278                NoteAttachment::with_word(NetworkAccountTarget::ATTACHMENT_SCHEME, word);
279
280            let target = NetworkAccountTarget::try_from(&attachment)?;
281            assert_eq!(target.target_id(), target_id);
282            assert_eq!(target.execution_hint(), NoteExecutionHint::Unknown(raw_hint));
283            // Re-encoding is lossless, so the note commitment is unaffected.
284            assert_eq!(NoteAttachment::from(target), attachment);
285        }
286
287        Ok(())
288    }
289
290    /// A caller-supplied target for the same account is kept as-is, so its execution hint survives
291    /// and no duplicate attachment is added.
292    #[test]
293    fn ensure_presence_keeps_matching_target() -> anyhow::Result<()> {
294        let target_id = public_account_id();
295        let supplied = NetworkAccountTarget::new(target_id, NoteExecutionHint::None)?;
296        let mut attachments = vec![NoteAttachment::from(supplied)];
297
298        NetworkAccountTarget::ensure_presence(&mut attachments, target_id)?;
299
300        assert_eq!(attachments.len(), 1);
301        assert_eq!(NetworkAccountTarget::try_from(&attachments[0])?, supplied);
302
303        Ok(())
304    }
305
306    /// A caller-supplied target for another account is rejected instead of being silently
307    /// shadowed by the note's own target.
308    #[test]
309    fn ensure_presence_rejects_mismatched_target() -> anyhow::Result<()> {
310        let target_id = public_account_id();
311        let other_id = public_account_id();
312        let supplied = NetworkAccountTarget::new(other_id, NoteExecutionHint::Always)?;
313        let mut attachments = vec![NoteAttachment::from(supplied)];
314
315        let err = NetworkAccountTarget::ensure_presence(&mut attachments, target_id).unwrap_err();
316
317        assert_matches!(
318            err,
319            NetworkAccountTargetError::TargetMismatch { expected, actual }
320                if expected == target_id && actual == other_id
321        );
322
323        Ok(())
324    }
325
326    /// The appended target is placed after the caller's attachments, leaving their order intact.
327    #[test]
328    fn ensure_presence_appends_missing_target() -> anyhow::Result<()> {
329        let target_id = public_account_id();
330        let unrelated =
331            NoteAttachment::with_word(NoteAttachmentScheme::new(64)?, Word::from([7u32, 0, 0, 0]));
332        let mut attachments = vec![unrelated.clone()];
333
334        NetworkAccountTarget::ensure_presence(&mut attachments, target_id)?;
335
336        assert_eq!(
337            attachments,
338            vec![
339                unrelated,
340                NoteAttachment::from(NetworkAccountTarget::new(
341                    target_id,
342                    NoteExecutionHint::Always
343                )?)
344            ]
345        );
346
347        Ok(())
348    }
349
350    /// A non-public target has no network routing target, so none is appended, but a
351    /// caller-supplied target for another account is still rejected.
352    #[test]
353    fn ensure_presence_if_public_skips_private_target() -> anyhow::Result<()> {
354        let private_id = AccountIdBuilder::new()
355            .account_type(AccountType::Private)
356            .build_with_rng(&mut rand::rng());
357        let mut attachments = vec![];
358
359        NetworkAccountTarget::ensure_presence_if_public(&mut attachments, private_id)?;
360        assert!(attachments.is_empty());
361
362        let other_id = public_account_id();
363        let supplied = NetworkAccountTarget::new(other_id, NoteExecutionHint::Always)?;
364        let mut attachments = vec![NoteAttachment::from(supplied)];
365
366        let err = NetworkAccountTarget::ensure_presence_if_public(&mut attachments, private_id)
367            .unwrap_err();
368
369        assert_matches!(
370            err,
371            NetworkAccountTargetError::TargetMismatch { expected, actual }
372                if expected == private_id && actual == other_id
373        );
374
375        Ok(())
376    }
377
378    #[test]
379    fn network_account_target_fails_on_private_target_account() -> anyhow::Result<()> {
380        let id = AccountIdBuilder::new()
381            .account_type(AccountType::Private)
382            .build_with_rng(&mut rand::rng());
383        let err = NetworkAccountTarget::new(id, NoteExecutionHint::Always).unwrap_err();
384
385        assert_matches!(
386            err,
387            NetworkAccountTargetError::TargetNotPublic(account_id) if account_id == id
388        );
389
390        Ok(())
391    }
392}