Skip to main content

miden_standards/note/
owner_config.rs

1use alloc::vec::Vec;
2
3use miden_protocol::account::AccountId;
4use miden_protocol::assembly::Path;
5use miden_protocol::crypto::rand::FeltRng;
6use miden_protocol::errors::NoteError;
7use miden_protocol::note::{
8    Note,
9    NoteAssets,
10    NoteAttachment,
11    NoteAttachments,
12    NoteRecipient,
13    NoteScript,
14    NoteScriptRoot,
15    NoteStorage,
16    NoteTag,
17    NoteType,
18    PartialNoteMetadata,
19};
20use miden_protocol::utils::sync::LazyLock;
21use miden_protocol::{Felt, Word};
22
23use crate::StandardsLib;
24use crate::note::costs::{NoteConsumptionCost, OWNER_CONFIG_CONSUMPTION_CYCLES};
25use crate::note::{AccountTargetNetworkNote, NetworkAccountTarget};
26
27// NOTE SCRIPT
28// ================================================================================================
29
30/// Path to the OWNER_CONFIG note script procedure in the standards library.
31const OWNER_CONFIG_SCRIPT_PATH: &str = "::miden::standards::notes::owner_config::main";
32
33// Initialize the OWNER_CONFIG note script only once.
34static OWNER_CONFIG_SCRIPT: LazyLock<NoteScript> = LazyLock::new(|| {
35    let standards_lib = StandardsLib::default();
36    let path = Path::new(OWNER_CONFIG_SCRIPT_PATH);
37    NoteScript::from_package_reference(standards_lib.as_ref(), path)
38        .expect("Standards library contains OWNER_CONFIG note script procedure")
39});
40
41// OWNER CONFIG
42// ================================================================================================
43
44/// A management action of the [`Ownable2Step`](crate::account::access::Ownable2Step) component
45/// that an [`OwnerConfigNote`] triggers on the account that consumes it.
46///
47/// The action, together with its arguments, is encoded into the note's storage (see
48/// [`NoteStorage`] conversion below). Because the storage is fixed at note creation and bound into
49/// the note commitment, the authorized party is the note sender: the consuming account's
50/// `Ownable2Step` procedures authorize against `active_note::get_sender`.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum OwnerConfig {
53    /// Nominate `new_owner` as the new owner (two-step transfer; the nominee must later accept via
54    /// [`OwnerConfig::AcceptOwnership`]). A `new_owner` of `None` cancels any pending nomination.
55    /// Only the current owner is authorized.
56    TransferOwnership { new_owner: Option<AccountId> },
57    /// Accept a pending ownership nomination. Only the nominated owner is authorized.
58    AcceptOwnership,
59    /// Renounce ownership, leaving the component permanently ownerless. Only the current owner is
60    /// authorized.
61    RenounceOwnership,
62}
63
64impl OwnerConfig {
65    // SELECTORS
66    // --------------------------------------------------------------------------------------------
67
68    // Config note selectors stored in the first storage item. Keep in sync with
69    // `owner_config.masm`.
70    const SELECTOR_TRANSFER_OWNERSHIP: u8 = 0;
71    const SELECTOR_ACCEPT_OWNERSHIP: u8 = 1;
72    const SELECTOR_RENOUNCE_OWNERSHIP: u8 = 2;
73
74    /// Returns the note storage values encoding this action, laid out as `[selector, ..args]`.
75    fn to_storage_values(self) -> Vec<Felt> {
76        match self {
77            OwnerConfig::TransferOwnership { new_owner } => {
78                // [selector, new_owner_suffix, new_owner_prefix]; the zero address (0, 0) is the
79                // cancel value understood by `ownable2step::transfer_ownership`.
80                let (suffix, prefix) = match new_owner {
81                    Some(id) => (id.suffix(), id.prefix().as_felt()),
82                    None => (Felt::ZERO, Felt::ZERO),
83                };
84                vec![Felt::from(Self::SELECTOR_TRANSFER_OWNERSHIP), suffix, prefix]
85            },
86            OwnerConfig::AcceptOwnership => {
87                vec![Felt::from(Self::SELECTOR_ACCEPT_OWNERSHIP)]
88            },
89            OwnerConfig::RenounceOwnership => {
90                vec![Felt::from(Self::SELECTOR_RENOUNCE_OWNERSHIP)]
91            },
92        }
93    }
94}
95
96impl From<OwnerConfig> for NoteStorage {
97    fn from(config: OwnerConfig) -> Self {
98        NoteStorage::new(config.to_storage_values())
99            .expect("number of storage items should not exceed max storage items")
100    }
101}
102
103// OWNER CONFIG NOTE
104// ================================================================================================
105
106/// An OwnerConfig note: triggers an [`Ownable2Step`](crate::account::access::Ownable2Step)
107/// management action on the account that consumes it.
108///
109/// A single note script dispatches on a selector in the note's storage to one of the component's
110/// management procedures (`transfer_ownership`, `accept_ownership`, `renounce_ownership`). All
111/// authorization is enforced by those procedures against the note sender, so the note carries no
112/// assets and its authorization is bound to `sender` at creation time.
113///
114/// The note is always public and tagged for `account` — the account carrying the `Ownable2Step`
115/// component whose ownership state is being managed. The `sender` is the account authorized for the
116/// selected action: the current owner for `TransferOwnership` / `RenounceOwnership`, or the
117/// nominated owner for `AcceptOwnership`.
118///
119/// The note is bound to the target `account` by a [`NetworkAccountTarget`] attachment: the script
120/// asserts that the consuming account matches that target before dispatching, so the note cannot be
121/// consumed by a third-party account that merely accepts its sender. The binding also
122/// makes the note a valid [`AccountTargetNetworkNote`], routing it to `account` for network
123/// execution.
124///
125/// Construct one with the [builder](OwnerConfigNote::builder); convert it into a protocol [`Note`]
126/// infallibly via `Note::from`.
127#[derive(Debug, Clone)]
128pub struct OwnerConfigNote {
129    sender: AccountId,
130    target: AccountId,
131    config: OwnerConfig,
132    serial_number: Word,
133    attachments: NoteAttachments,
134}
135
136#[bon::bon]
137impl OwnerConfigNote {
138    /// Builds a new [`OwnerConfigNote`] that applies `config` to `account`.
139    ///
140    /// The note is bound to `account` by a [`NetworkAccountTarget`] attachment that the builder
141    /// appends unless the caller already supplied one for `account`.
142    ///
143    /// # Errors
144    ///
145    /// Returns an error if:
146    /// - `account` is not a public account (the note is bound to it via a `NetworkAccountTarget`,
147    ///   which requires a public target).
148    /// - the attachments carry a `NetworkAccountTarget` for an account other than `account`.
149    /// - the attachments exceed their protocol limit (see [`NoteAttachments::new`]); the target
150    ///   attachment occupies one of the available slots when the caller does not supply it.
151    #[builder]
152    pub fn new(
153        #[builder(field)] mut attachments: Vec<NoteAttachment>,
154        sender: AccountId,
155        target: AccountId,
156        config: OwnerConfig,
157        serial_number: Word,
158    ) -> Result<Self, NoteError> {
159        // The note script asserts that the consuming account matches this target before
160        // dispatching.
161        NetworkAccountTarget::ensure_presence(&mut attachments, target).map_err(|err| {
162            NoteError::other_with_source(
163                "failed to bind the OwnerConfig note to its target account",
164                err,
165            )
166        })?;
167        let attachments = NoteAttachments::new(attachments)?;
168
169        Ok(Self {
170            sender,
171            target,
172            config,
173            serial_number,
174            attachments,
175        })
176    }
177}
178
179impl OwnerConfigNote {
180    // CONSTANTS
181    // --------------------------------------------------------------------------------------------
182
183    /// Upper bound on the number of storage items of an OwnerConfig note.
184    ///
185    /// The layout is variable: `TransferOwnership` uses 3 items (`[selector, new_owner_suffix,
186    /// new_owner_prefix]`), while `AcceptOwnership` / `RenounceOwnership` use 1 (`[selector]`).
187    pub const MAX_NUM_STORAGE_ITEMS: usize = 3;
188
189    // PUBLIC ACCESSORS
190    // --------------------------------------------------------------------------------------------
191
192    /// Returns the script of the OwnerConfig note.
193    pub fn script() -> NoteScript {
194        OWNER_CONFIG_SCRIPT.clone()
195    }
196
197    /// Returns the OwnerConfig note script root.
198    pub fn script_root() -> NoteScriptRoot {
199        OWNER_CONFIG_SCRIPT.root()
200    }
201
202    /// Returns the account ID of the note's sender (the account authorized for the action).
203    pub fn sender(&self) -> AccountId {
204        self.sender
205    }
206
207    /// Returns the account ID of the managed account (the account the note is tagged for).
208    pub fn account(&self) -> AccountId {
209        self.target
210    }
211
212    /// Returns the management action carried by the note.
213    pub fn config(&self) -> OwnerConfig {
214        self.config
215    }
216
217    /// Returns the note's serial number.
218    pub fn serial_number(&self) -> Word {
219        self.serial_number
220    }
221
222    /// Returns the attachments carried by the note, which always include a
223    /// [`NetworkAccountTarget`].
224    pub fn attachments(&self) -> &NoteAttachments {
225        &self.attachments
226    }
227}
228
229// BUILDER EXTENSIONS
230// ================================================================================================
231
232impl<S: owner_config_note_builder::State> OwnerConfigNoteBuilder<S> {
233    /// Adds a single attachment to the note.
234    pub fn attachment(mut self, attachment: impl Into<NoteAttachment>) -> Self {
235        self.attachments.push(attachment.into());
236        self
237    }
238
239    /// Adds multiple attachments to the note.
240    pub fn attachments(
241        mut self,
242        attachments: impl IntoIterator<Item = impl Into<NoteAttachment>>,
243    ) -> Self {
244        self.attachments.extend(attachments.into_iter().map(Into::into));
245        self
246    }
247}
248
249impl<S: owner_config_note_builder::State> OwnerConfigNoteBuilder<S>
250where
251    S::SerialNumber: owner_config_note_builder::IsUnset,
252{
253    /// Draws a serial number from `rng` and sets it on the builder.
254    pub fn generate_serial_number(
255        self,
256        rng: &mut impl FeltRng,
257    ) -> OwnerConfigNoteBuilder<owner_config_note_builder::SetSerialNumber<S>> {
258        self.serial_number(rng.draw_word())
259    }
260}
261
262// CONVERSIONS
263// ================================================================================================
264
265impl From<OwnerConfigNote> for Note {
266    fn from(note: OwnerConfigNote) -> Self {
267        // OwnerConfig notes carry no assets and are always public for network execution; the action
268        // and its arguments live in the note storage.
269        let metadata = PartialNoteMetadata::new(note.sender, NoteType::Public)
270            .with_tag(NoteTag::with_account_target(note.target));
271        let recipient = NoteRecipient::new(
272            note.serial_number,
273            OwnerConfigNote::script(),
274            NoteStorage::from(note.config),
275        );
276        Note::with_attachments(NoteAssets::default(), metadata, recipient, note.attachments)
277    }
278}
279
280impl From<OwnerConfigNote> for AccountTargetNetworkNote {
281    fn from(note: OwnerConfigNote) -> Self {
282        AccountTargetNetworkNote::new(Note::from(note))
283            .expect("OwnerConfig note is public and carries a network account target attachment")
284    }
285}
286
287// NOTE CONSUMPTION COST
288// ================================================================================================
289
290impl NoteConsumptionCost for OwnerConfigNote {
291    fn consumption_cycles() -> u32 {
292        OWNER_CONFIG_CONSUMPTION_CYCLES
293    }
294}
295
296// TESTS
297// ================================================================================================
298
299#[cfg(test)]
300mod tests {
301    use assert_matches::assert_matches;
302    use miden_protocol::account::AccountType;
303    use miden_protocol::crypto::rand::RandomCoin;
304    use miden_protocol::note::NoteAttachmentScheme;
305
306    use super::*;
307    use crate::note::{NetworkAccountTargetError, NetworkNoteExt, NoteExecutionHint};
308
309    fn account_id(seed: u8) -> AccountId {
310        typed_account_id(seed, AccountType::Public)
311    }
312
313    fn typed_account_id(seed: u8, account_type: AccountType) -> AccountId {
314        AccountId::builder().account_type(account_type).build_with_seed([seed; 32])
315    }
316
317    /// The builder produces a public, asset-less note tagged for the managed account.
318    #[test]
319    fn builder_builds_owner_config_note() {
320        let mut rng = RandomCoin::new(Word::empty());
321        let managed = account_id(1);
322        let owner = account_id(2);
323        let new_owner = account_id(3);
324
325        let note = OwnerConfigNote::builder()
326            .sender(owner)
327            .target(managed)
328            .config(OwnerConfig::TransferOwnership { new_owner: Some(new_owner) })
329            .generate_serial_number(&mut rng)
330            .build()
331            .unwrap();
332
333        assert_eq!(note.sender(), owner);
334        assert_eq!(note.account(), managed);
335
336        let note = Note::from(note);
337        assert_eq!(note.metadata().note_type(), NoteType::Public);
338        assert_eq!(note.metadata().tag(), NoteTag::with_account_target(managed));
339        assert_eq!(note.assets().num_assets(), 0);
340    }
341
342    /// The builder attaches the network target for the managed account, so the note is a network
343    /// note without the caller having to add the attachment.
344    #[test]
345    fn builder_attaches_network_target() {
346        let mut rng = RandomCoin::new(Word::empty());
347        let managed = account_id(1);
348
349        let note = OwnerConfigNote::builder()
350            .sender(account_id(2))
351            .target(managed)
352            .config(OwnerConfig::AcceptOwnership)
353            .generate_serial_number(&mut rng)
354            .build()
355            .unwrap();
356
357        assert_eq!(note.attachments().num_attachments(), 1);
358
359        let network_note = AccountTargetNetworkNote::from(note);
360        assert_eq!(network_note.target_account_id(), managed);
361        assert_eq!(network_note.execution_hint(), NoteExecutionHint::Always);
362        assert!(network_note.as_note().is_network_note());
363    }
364
365    /// Caller-supplied attachments are kept in their order, with the bound network target appended.
366    #[test]
367    fn builder_keeps_caller_attachments() {
368        let mut rng = RandomCoin::new(Word::empty());
369        let managed = account_id(1);
370        let custom_scheme = NoteAttachmentScheme::new(64).unwrap();
371        let custom = NoteAttachment::with_word(custom_scheme, Word::from([7u32, 0, 0, 0]));
372
373        let note = OwnerConfigNote::builder()
374            .attachment(custom.clone())
375            .sender(account_id(2))
376            .target(managed)
377            .config(OwnerConfig::AcceptOwnership)
378            .generate_serial_number(&mut rng)
379            .build()
380            .unwrap();
381
382        // The target is appended, so the caller's attachment comes first.
383        assert_eq!(note.attachments().num_attachments(), 2);
384        assert_eq!(note.attachments().get(0), Some(&custom));
385
386        let network_note = AccountTargetNetworkNote::from(note);
387        assert_eq!(network_note.target_account_id(), managed);
388    }
389
390    /// A caller-supplied `NetworkAccountTarget` for another account is rejected rather than
391    /// silently coexisting with the note's own target.
392    #[test]
393    fn builder_rejects_target_for_other_account() {
394        let mut rng = RandomCoin::new(Word::empty());
395        let rogue_target =
396            NetworkAccountTarget::new(account_id(3), NoteExecutionHint::None).unwrap();
397
398        let err = OwnerConfigNote::builder()
399            .attachment(rogue_target)
400            .sender(account_id(2))
401            .target(account_id(1))
402            .config(OwnerConfig::AcceptOwnership)
403            .generate_serial_number(&mut rng)
404            .build()
405            .unwrap_err();
406
407        assert_matches!(err, NoteError::Other { source, .. } => {
408            assert_matches!(
409              *source.unwrap().downcast().unwrap(),
410              NetworkAccountTargetError::TargetMismatch { .. }
411            )
412        });
413    }
414
415    /// A non-public managed account cannot be a network target, so the builder rejects it.
416    #[test]
417    fn builder_rejects_non_public_account() {
418        let mut rng = RandomCoin::new(Word::empty());
419        let managed = typed_account_id(1, AccountType::Private);
420
421        let err = OwnerConfigNote::builder()
422            .sender(account_id(2))
423            .target(managed)
424            .config(OwnerConfig::AcceptOwnership)
425            .generate_serial_number(&mut rng)
426            .build()
427            .unwrap_err();
428
429        assert_matches!(err, NoteError::Other { source, .. } => {
430            assert_matches!(
431              *source.unwrap().downcast().unwrap(),
432              NetworkAccountTargetError::TargetNotPublic { .. }
433            )
434        });
435    }
436
437    /// `TransferOwnership` storage is `[selector, new_owner_suffix, new_owner_prefix]`.
438    #[test]
439    fn transfer_ownership_storage_layout() {
440        let new_owner = account_id(3);
441        let storage =
442            NoteStorage::from(OwnerConfig::TransferOwnership { new_owner: Some(new_owner) });
443
444        assert_eq!(
445            storage.items(),
446            &[
447                Felt::from(OwnerConfig::SELECTOR_TRANSFER_OWNERSHIP),
448                new_owner.suffix(),
449                new_owner.prefix().as_felt(),
450            ]
451        );
452    }
453
454    /// A cancelling `TransferOwnership` encodes the zero address.
455    #[test]
456    fn cancel_transfer_ownership_storage_layout() {
457        let storage = NoteStorage::from(OwnerConfig::TransferOwnership { new_owner: None });
458
459        assert_eq!(
460            storage.items(),
461            &[Felt::from(OwnerConfig::SELECTOR_TRANSFER_OWNERSHIP), Felt::ZERO, Felt::ZERO]
462        );
463    }
464
465    /// `AcceptOwnership` / `RenounceOwnership` storage is a single selector item.
466    #[test]
467    fn accept_and_renounce_storage_layout() {
468        let accept = NoteStorage::from(OwnerConfig::AcceptOwnership);
469        assert_eq!(accept.items(), &[Felt::from(OwnerConfig::SELECTOR_ACCEPT_OWNERSHIP)]);
470
471        let renounce = NoteStorage::from(OwnerConfig::RenounceOwnership);
472        assert_eq!(renounce.items(), &[Felt::from(OwnerConfig::SELECTOR_RENOUNCE_OWNERSHIP)]);
473    }
474}