Skip to content

Add Rust types with builder API for standard notes #2283

@PhilippGackstatter

Description

@PhilippGackstatter

Motivation

Having a type that represents a note, e.g. P2idNote, is useful for a few reasons:

  • It represents a single point where P2ID related functionality can be defined, e.g. building recipients or note inputs or a getter for the note script root.
  • In the future, these types can implement NoteInterface. This would allow checking compatibility with an AccountInterface, as an example. This interface should be covered by a separate issue.
  • It allows writing a builder API for convenient creation of this type.
    • This is useful because many parameters for notes are optional:
      • note type can default to private
      • attachment is optional
      • assets can be empty by default
      • reclaim or timelock functionality on P2IDE is optional
    • The functions we currently have for creating them, like create_p2ide_note require all parameters which makes it cumbersome to use. This is a good use case for builder-like APIs that automatically set the defaults.
  • Once a P2idNote is created, it should be possible to convert it into Note infallibly, e.g. Note::from(p2id_note), allowing abstractions that take impl Into<Note>.
  • With NoteExecutionHint moving from protocol to standards, we could add a network account target-based extension trait that builds on top of NoteInterface that has an execution_hint(&self) -> NoteExecutionHint method. It returns the hint for this note. This would be particularly useful for the P2IDE note to encode its timelock into an execution hint. It would be a way for standard notes to implement the "network account target standard".

P2ID Example

Note that NoteInputs were recently renamed to NoteStorage which should be updated in the following example.

// Imports omitted

// P2ID Inputs
// ----------------------------------------------

// A type for a note's inputs is likely useful: A Swap note could take the P2IdInputs
// for the payback note as a parameter.
// Also mentioned in https://github.com/0xMiden/miden-base/pull/2249#discussion_r2679329498.
#[derive(Debug, Clone, Copy)]
pub struct P2IdInputs {
    target: AccountId,
}

impl P2IdInputs {
    /// Consumes the inputs and returns a P2ID [`NoteRecipient`] with the provided serial number.
    ///
    /// Notes created with this recipient will be P2ID notes consumable by the specified target
    /// account stored in [`P2IdInputs`].
    ///
    /// TODO: This could be a trait to make all standard note inputs convertible into `NoteRecipient`
    /// when a serial is provided - though not clear yet if this abstraction is needed, so doesn't
    /// need to be part of the initial version.
    pub fn into_recipient(self, serial_num: Word) -> NoteRecipient {
        NoteRecipient::new(serial_num, WellKnownNote::P2ID.script(), NoteInputs::from(self))
    }
}

impl From<P2IdInputs> for NoteInputs {
    fn from(inputs: P2IdInputs) -> Self {
        NoteInputs::new(vec![inputs.target.suffix(), inputs.target.prefix().as_felt()])
            .expect("number of inputs should be below max")
    }
}

// P2ID Note
// ----------------------------------------------

#[derive(Debug, Clone, bon::Builder)]
#[builder(finish_fn(vis = "", name = build_internal))]
pub struct P2IdNote {
    sender: AccountId,

    #[builder(name = target, with = |target: AccountId| P2IdInputs { target })]
    inputs: P2IdInputs,

    serial_number: Word,

    #[builder(default = NoteType::Private)]
    note_type: NoteType,

    #[builder(default)]
    assets: NoteAssets,

    #[builder(default)]
    attachment: NoteAttachment,
}

impl P2IdNote {
    pub fn sender(&self) -> AccountId {
        self.sender
    }

    // TODO: Getters for all fields, written by hand.
    // ...
}

// Extend the builder with a custom method to allow generating a serial
// number from an RNG. This is optional, but nice to have for basically any note.
impl<S: State> P2IdNoteBuilder<S>
where
    S::SerialNumber: IsUnset,
{
    pub fn serial_number_from_rng(
        self,
        rng: &mut impl FeltRng,
    ) -> P2IdNoteBuilder<SetSerialNumber<S>> {
        self.serial_number(rng.draw_word())
    }
}

// Use a custom build method for custom validation.
impl<S: IsComplete> P2IdNoteBuilder<S> {
    pub fn build(self) -> Result<P2IdNote, NoteError> {
        let note = self.build_internal();

        // This is just an example.
        if note.inputs.target.storage_mode().is_network() && note.note_type != NoteType::Public {
            return Err(NoteError::other("network targets require public notes"));
        }

        Ok(note)
    }
}

// Converting to a Note should be infallible.
impl From<P2IdNote> for Note {
    fn from(note: P2IdNote) -> Self {
        let recipient = note.inputs.into_recipient(note.serial_number);
        let tag = NoteTag::with_account_target(note.inputs.target);
        let metadata =
            NoteMetadata::new(note.sender, note.note_type, tag).with_attachment(note.attachment);

        Note::new(note.assets, metadata, recipient)
    }
}

// Tests showcasing usage
// ----------------------------------------------

#[cfg(test)]
mod tests2 {
    use miden_processor::crypto::RpoRandomCoin;
    use miden_protocol::Word;
    use miden_protocol::asset::FungibleAsset;
    use miden_protocol::note::{Note, NoteAssets, NoteAttachment, NoteAttachmentScheme, NoteType};
    use miden_protocol::testing::account_id::{
        ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET,
        ACCOUNT_ID_SENDER,
    };

    use crate::note::P2IdNote;

    #[test]
    fn test_p2id_builder() -> anyhow::Result<()> {
        let sender = ACCOUNT_ID_SENDER.try_into()?;
        let target = ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET.try_into()?;

        // Minimal example
        let p2id_note0 = P2IdNote::builder()
            .sender(sender)
            .target(target)
            .serial_number_from_rng(&mut RpoRandomCoin::new(Word::empty()))
            .build()?;

        // Full example
        let p2id_note1 = P2IdNote::builder()
            .sender(sender)
            .target(target)
            .serial_number(Word::empty())
            .assets(NoteAssets::new(vec![FungibleAsset::mock(34)])?)
            .attachment(NoteAttachment::new_word(
                NoteAttachmentScheme::default(),
                Word::from([1, 2, 3, 4u32]),
            ))
            .note_type(NoteType::Public)
            .build()?;

        let note0 = Note::from(p2id_note0.clone());
        let note1 = Note::from(p2id_note1.clone());

        assert_eq!(note0.assets(), &p2id_note0.assets);
        assert_eq!(note1.assets(), &p2id_note1.assets);

        Ok(())
    }
}

Bon Crate

Is bon absolutely necessary? No, it's just convenient to take away boilerplate which we would have to implement for every standard note (a lot). Plus, the typestate pattern is a nice bonus, ensuring that all fields are set and the absence of fields doesn't have to be handled at runtime.

Semver Compatibility

When using bon::Builder, adding a new field to P2idNote that implements Default is not a breaking change, which is a nice bonus. Tested by adding:

#[builder(default)]
is_encrypted: bool,

and bumping the version from 0.13.0 to 0.13.1, then running cargo semver-checks -p miden-standards --baseline-rev <prev_commit>, which printed "no semver update required".

Conclusion

I think a builder pattern makes sense to use for notes:

  • Notes have many optional parameters and this makes a single create function cumbersome.
  • Having a type for every note will likely be required for implementing NoteInterface.

bon would take away most of the pain to create these builders, so using it seems ideal to me.

Metadata

Metadata

Assignees

No one assigned

    Labels

    good first issueGood for newcomersrustIssues that affect or pull requests that update Rust code

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions