Skip to main content

miden_standards/note/config/
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, NumStorageItems};
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    // VARIANTS
66    // --------------------------------------------------------------------------------------------
67
68    // Config note variants stored in the first storage item. Keep in sync with
69    // `owner_config.masm`.
70    const VARIANT_TRANSFER_OWNERSHIP: u8 = 0;
71    const VARIANT_ACCEPT_OWNERSHIP: u8 = 1;
72    const VARIANT_RENOUNCE_OWNERSHIP: u8 = 2;
73
74    /// Returns the note storage values encoding this action, laid out as `[variant, ..args]`.
75    fn to_storage_values(self) -> Vec<Felt> {
76        match self {
77            OwnerConfig::TransferOwnership { new_owner } => {
78                // [variant, 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::VARIANT_TRANSFER_OWNERSHIP), suffix, prefix]
85            },
86            OwnerConfig::AcceptOwnership => {
87                vec![Felt::from(Self::VARIANT_ACCEPT_OWNERSHIP)]
88            },
89            OwnerConfig::RenounceOwnership => {
90                vec![Felt::from(Self::VARIANT_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 the note variant in its 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/// The note must be public: the script rejects a non-public note. See
126/// [the module docs](crate::note::config#note-type) for the layers that enforce it.
127///
128/// Construct one with the [builder](OwnerConfigNote::builder); convert it into a protocol [`Note`]
129/// infallibly via `Note::from`.
130#[derive(Debug, Clone)]
131pub struct OwnerConfigNote {
132    sender: AccountId,
133    target: AccountId,
134    config: OwnerConfig,
135    serial_number: Word,
136    attachments: NoteAttachments,
137}
138
139#[bon::bon]
140impl OwnerConfigNote {
141    /// Builds a new [`OwnerConfigNote`] that applies `config` to `account`.
142    ///
143    /// The note is bound to `account` by a [`NetworkAccountTarget`] attachment that the builder
144    /// appends unless the caller already supplied one for `account`.
145    ///
146    /// # Errors
147    ///
148    /// Returns an error if:
149    /// - `account` is not a public account (the note is bound to it via a `NetworkAccountTarget`,
150    ///   which requires a public target).
151    /// - the attachments carry a `NetworkAccountTarget` for an account other than `account`.
152    /// - the attachments exceed their protocol limit (see [`NoteAttachments::new`]); the target
153    ///   attachment occupies one of the available slots when the caller does not supply it.
154    #[builder]
155    pub fn new(
156        #[builder(field)] mut attachments: Vec<NoteAttachment>,
157        sender: AccountId,
158        target: AccountId,
159        config: OwnerConfig,
160        serial_number: Word,
161    ) -> Result<Self, NoteError> {
162        // The note script asserts that the consuming account matches this target before
163        // dispatching.
164        NetworkAccountTarget::ensure_presence(&mut attachments, target).map_err(|err| {
165            NoteError::other_with_source(
166                "failed to bind the OwnerConfig note to its target account",
167                err,
168            )
169        })?;
170        let attachments = NoteAttachments::new(attachments)?;
171
172        Ok(Self {
173            sender,
174            target,
175            config,
176            serial_number,
177            attachments,
178        })
179    }
180}
181
182impl OwnerConfigNote {
183    // CONSTANTS
184    // --------------------------------------------------------------------------------------------
185
186    /// The numbers of storage items the OwnerConfig note script accepts.
187    ///
188    /// The layout is variable: `TransferOwnership` uses 3 items (`[variant, new_owner_suffix,
189    /// new_owner_prefix]`), while `AcceptOwnership` / `RenounceOwnership` use 1 (`[variant]`).
190    /// Keep in sync with the `NUM_ITEMS_*` constants in `owner_config.masm`.
191    pub const NUM_STORAGE_ITEMS: NumStorageItems =
192        NumStorageItems::AnyOf(&[NumStorageItems::Exact(1), NumStorageItems::Exact(3)]);
193
194    // PUBLIC ACCESSORS
195    // --------------------------------------------------------------------------------------------
196
197    /// Returns the script of the OwnerConfig note.
198    pub fn script() -> NoteScript {
199        OWNER_CONFIG_SCRIPT.clone()
200    }
201
202    /// Returns the OwnerConfig note script root.
203    pub fn script_root() -> NoteScriptRoot {
204        OWNER_CONFIG_SCRIPT.root()
205    }
206
207    /// Returns the account ID of the note's sender (the account authorized for the action).
208    pub fn sender(&self) -> AccountId {
209        self.sender
210    }
211
212    /// Returns the account ID of the managed account (the account the note is tagged for).
213    pub fn target(&self) -> AccountId {
214        self.target
215    }
216
217    /// Returns the management action carried by the note.
218    pub fn config(&self) -> OwnerConfig {
219        self.config
220    }
221
222    /// Returns the note's serial number.
223    pub fn serial_number(&self) -> Word {
224        self.serial_number
225    }
226
227    /// Returns the attachments carried by the note, which always include a
228    /// [`NetworkAccountTarget`].
229    pub fn attachments(&self) -> &NoteAttachments {
230        &self.attachments
231    }
232}
233
234// BUILDER EXTENSIONS
235// ================================================================================================
236
237impl<S: owner_config_note_builder::State> OwnerConfigNoteBuilder<S> {
238    /// Adds a single attachment to the note.
239    pub fn attachment(mut self, attachment: impl Into<NoteAttachment>) -> Self {
240        self.attachments.push(attachment.into());
241        self
242    }
243
244    /// Adds multiple attachments to the note.
245    pub fn attachments(
246        mut self,
247        attachments: impl IntoIterator<Item = impl Into<NoteAttachment>>,
248    ) -> Self {
249        self.attachments.extend(attachments.into_iter().map(Into::into));
250        self
251    }
252}
253
254impl<S: owner_config_note_builder::State> OwnerConfigNoteBuilder<S>
255where
256    S::SerialNumber: owner_config_note_builder::IsUnset,
257{
258    /// Draws a serial number from `rng` and sets it on the builder.
259    pub fn generate_serial_number(
260        self,
261        rng: &mut impl FeltRng,
262    ) -> OwnerConfigNoteBuilder<owner_config_note_builder::SetSerialNumber<S>> {
263        self.serial_number(rng.draw_word())
264    }
265}
266
267// CONVERSIONS
268// ================================================================================================
269
270impl From<OwnerConfigNote> for Note {
271    fn from(note: OwnerConfigNote) -> Self {
272        // OwnerConfig notes carry no assets and are always public for network execution; the action
273        // and its arguments live in the note storage.
274        let metadata = PartialNoteMetadata::new(note.sender, NoteType::Public)
275            .with_tag(NoteTag::with_account_target(note.target));
276        let recipient = NoteRecipient::new(
277            note.serial_number,
278            OwnerConfigNote::script(),
279            NoteStorage::from(note.config),
280        );
281        Note::with_attachments(NoteAssets::default(), metadata, recipient, note.attachments)
282    }
283}
284
285impl From<OwnerConfigNote> for AccountTargetNetworkNote {
286    fn from(note: OwnerConfigNote) -> Self {
287        AccountTargetNetworkNote::new(Note::from(note))
288            .expect("OwnerConfig note is public and carries a network account target attachment")
289    }
290}
291
292// NOTE CONSUMPTION COST
293// ================================================================================================
294
295impl NoteConsumptionCost for OwnerConfigNote {
296    fn consumption_cycles() -> u32 {
297        OWNER_CONFIG_CONSUMPTION_CYCLES
298    }
299}
300
301// TESTS
302// ================================================================================================
303
304#[cfg(test)]
305mod tests {
306    use assert_matches::assert_matches;
307    use miden_protocol::account::AccountType;
308    use miden_protocol::crypto::rand::RandomCoin;
309    use miden_protocol::note::NoteAttachmentScheme;
310
311    use super::*;
312    use crate::note::{NetworkAccountTargetError, NetworkNoteExt, NoteExecutionHint};
313
314    fn account_id(seed: u8) -> AccountId {
315        typed_account_id(seed, AccountType::Public)
316    }
317
318    fn typed_account_id(seed: u8, account_type: AccountType) -> AccountId {
319        AccountId::builder().account_type(account_type).build_with_seed([seed; 32])
320    }
321
322    /// The builder produces a public, asset-less note tagged for the managed account.
323    #[test]
324    fn builder_builds_owner_config_note() {
325        let mut rng = RandomCoin::new(Word::empty());
326        let managed = account_id(1);
327        let owner = account_id(2);
328        let new_owner = account_id(3);
329
330        let note = OwnerConfigNote::builder()
331            .sender(owner)
332            .target(managed)
333            .config(OwnerConfig::TransferOwnership { new_owner: Some(new_owner) })
334            .generate_serial_number(&mut rng)
335            .build()
336            .unwrap();
337
338        assert_eq!(note.sender(), owner);
339        assert_eq!(note.target(), managed);
340
341        let note = Note::from(note);
342        assert_eq!(note.metadata().note_type(), NoteType::Public);
343        assert_eq!(note.metadata().tag(), NoteTag::with_account_target(managed));
344        assert_eq!(note.assets().num_assets(), 0);
345    }
346
347    /// The builder attaches the network target for the managed account, so the note is a network
348    /// note without the caller having to add the attachment.
349    #[test]
350    fn builder_attaches_network_target() {
351        let mut rng = RandomCoin::new(Word::empty());
352        let managed = account_id(1);
353
354        let note = OwnerConfigNote::builder()
355            .sender(account_id(2))
356            .target(managed)
357            .config(OwnerConfig::AcceptOwnership)
358            .generate_serial_number(&mut rng)
359            .build()
360            .unwrap();
361
362        assert_eq!(note.attachments().num_attachments(), 1);
363
364        let network_note = AccountTargetNetworkNote::from(note);
365        assert_eq!(network_note.target_account_id(), managed);
366        assert_eq!(network_note.execution_hint(), NoteExecutionHint::Always);
367        assert!(network_note.as_note().is_network_note());
368    }
369
370    /// Caller-supplied attachments are kept in their order, with the bound network target appended.
371    #[test]
372    fn builder_keeps_caller_attachments() {
373        let mut rng = RandomCoin::new(Word::empty());
374        let managed = account_id(1);
375        let custom_scheme = NoteAttachmentScheme::new(64).unwrap();
376        let custom = NoteAttachment::with_word(custom_scheme, Word::from([7u32, 0, 0, 0]));
377
378        let note = OwnerConfigNote::builder()
379            .attachment(custom.clone())
380            .sender(account_id(2))
381            .target(managed)
382            .config(OwnerConfig::AcceptOwnership)
383            .generate_serial_number(&mut rng)
384            .build()
385            .unwrap();
386
387        // The target is appended, so the caller's attachment comes first.
388        assert_eq!(note.attachments().num_attachments(), 2);
389        assert_eq!(note.attachments().get(0), Some(&custom));
390
391        let network_note = AccountTargetNetworkNote::from(note);
392        assert_eq!(network_note.target_account_id(), managed);
393    }
394
395    /// A caller-supplied `NetworkAccountTarget` for another account is rejected rather than
396    /// silently coexisting with the note's own target.
397    #[test]
398    fn builder_rejects_target_for_other_account() {
399        let mut rng = RandomCoin::new(Word::empty());
400        let rogue_target =
401            NetworkAccountTarget::new(account_id(3), NoteExecutionHint::None).unwrap();
402
403        let err = OwnerConfigNote::builder()
404            .attachment(rogue_target)
405            .sender(account_id(2))
406            .target(account_id(1))
407            .config(OwnerConfig::AcceptOwnership)
408            .generate_serial_number(&mut rng)
409            .build()
410            .unwrap_err();
411
412        assert_matches!(err, NoteError::Other { source, .. } => {
413            assert_matches!(
414              *source.unwrap().downcast().unwrap(),
415              NetworkAccountTargetError::TargetMismatch { .. }
416            )
417        });
418    }
419
420    /// A non-public managed account cannot be a network target, so the builder rejects it.
421    #[test]
422    fn builder_rejects_non_public_account() {
423        let mut rng = RandomCoin::new(Word::empty());
424        let managed = typed_account_id(1, AccountType::Private);
425
426        let err = OwnerConfigNote::builder()
427            .sender(account_id(2))
428            .target(managed)
429            .config(OwnerConfig::AcceptOwnership)
430            .generate_serial_number(&mut rng)
431            .build()
432            .unwrap_err();
433
434        assert_matches!(err, NoteError::Other { source, .. } => {
435            assert_matches!(
436              *source.unwrap().downcast().unwrap(),
437              NetworkAccountTargetError::TargetNotPublic { .. }
438            )
439        });
440    }
441
442    /// `TransferOwnership` storage is `[variant, new_owner_suffix, new_owner_prefix]`.
443    #[test]
444    fn transfer_ownership_storage_layout() {
445        let new_owner = account_id(3);
446        let storage =
447            NoteStorage::from(OwnerConfig::TransferOwnership { new_owner: Some(new_owner) });
448
449        assert_eq!(
450            storage.items(),
451            &[
452                Felt::from(OwnerConfig::VARIANT_TRANSFER_OWNERSHIP),
453                new_owner.suffix(),
454                new_owner.prefix().as_felt(),
455            ]
456        );
457    }
458
459    /// A cancelling `TransferOwnership` encodes the zero address.
460    #[test]
461    fn cancel_transfer_ownership_storage_layout() {
462        let storage = NoteStorage::from(OwnerConfig::TransferOwnership { new_owner: None });
463
464        assert_eq!(
465            storage.items(),
466            &[Felt::from(OwnerConfig::VARIANT_TRANSFER_OWNERSHIP), Felt::ZERO, Felt::ZERO]
467        );
468    }
469
470    /// `AcceptOwnership` / `RenounceOwnership` storage is a single variant item.
471    #[test]
472    fn accept_and_renounce_storage_layout() {
473        let accept = NoteStorage::from(OwnerConfig::AcceptOwnership);
474        assert_eq!(accept.items(), &[Felt::from(OwnerConfig::VARIANT_ACCEPT_OWNERSHIP)]);
475
476        let renounce = NoteStorage::from(OwnerConfig::RenounceOwnership);
477        assert_eq!(renounce.items(), &[Felt::from(OwnerConfig::VARIANT_RENOUNCE_OWNERSHIP)]);
478    }
479}