use alloc::vec::Vec;
use miden_protocol::account::AccountId;
use miden_protocol::assembly::Path;
use miden_protocol::asset::Asset;
use miden_protocol::block::BlockNumber;
use miden_protocol::crypto::rand::FeltRng;
use miden_protocol::errors::NoteError;
use miden_protocol::note::{
Note,
NoteAssets,
NoteAttachment,
NoteAttachments,
NoteRecipient,
NoteScript,
NoteScriptRoot,
NoteStorage,
NoteTag,
NoteType,
PartialNoteMetadata,
};
use miden_protocol::utils::sync::LazyLock;
use miden_protocol::{Felt, Word};
use crate::StandardsLib;
const P2IDE_SCRIPT_PATH: &str = "::miden::standards::notes::p2ide::main";
static P2IDE_SCRIPT: LazyLock<NoteScript> = LazyLock::new(|| {
let standards_lib = StandardsLib::default();
let path = Path::new(P2IDE_SCRIPT_PATH);
NoteScript::from_library_reference(standards_lib.as_ref(), path)
.expect("Standards library contains P2IDE note script procedure")
});
#[derive(Debug, Clone)]
pub struct P2ideNote {
sender: AccountId,
storage: P2ideNoteStorage,
serial_number: Word,
note_type: NoteType,
assets: NoteAssets,
attachments: NoteAttachments,
}
#[bon::bon]
impl P2ideNote {
#[builder]
pub fn new(
#[builder(field)] assets: Vec<Asset>,
#[builder(field)] attachments: Vec<NoteAttachment>,
sender: AccountId,
target: AccountId,
reclaimer: Option<AccountId>,
reclaim_height: Option<BlockNumber>,
timelock_height: Option<BlockNumber>,
serial_number: Word,
#[builder(default)] note_type: NoteType,
) -> Result<Self, NoteError> {
if assets.is_empty() {
return Err(NoteError::other("a P2IDE note must contain at least one asset"));
}
let reclaimer = reclaimer.unwrap_or(sender);
let storage = P2ideNoteStorage::new(reclaimer, target, reclaim_height, timelock_height);
let assets = NoteAssets::new(assets)?;
let attachments = NoteAttachments::new(attachments)?;
Ok(Self {
sender,
storage,
serial_number,
note_type,
assets,
attachments,
})
}
}
impl P2ideNote {
pub const NUM_STORAGE_ITEMS: usize = P2ideNoteStorage::NUM_ITEMS;
pub fn script() -> NoteScript {
P2IDE_SCRIPT.clone()
}
pub fn script_root() -> NoteScriptRoot {
P2IDE_SCRIPT.root()
}
pub fn sender(&self) -> AccountId {
self.sender
}
pub fn storage(&self) -> P2ideNoteStorage {
self.storage
}
pub fn target(&self) -> AccountId {
self.storage.target()
}
pub fn reclaimer(&self) -> AccountId {
self.storage.reclaimer()
}
pub fn reclaim_height(&self) -> Option<BlockNumber> {
self.storage.reclaim_height()
}
pub fn timelock_height(&self) -> Option<BlockNumber> {
self.storage.timelock_height()
}
pub fn serial_number(&self) -> Word {
self.serial_number
}
pub fn note_type(&self) -> NoteType {
self.note_type
}
pub fn assets(&self) -> &NoteAssets {
&self.assets
}
pub fn attachments(&self) -> &NoteAttachments {
&self.attachments
}
}
impl<S: p2ide_note_builder::State> P2ideNoteBuilder<S> {
pub fn asset(mut self, asset: impl Into<Asset>) -> Self {
self.assets.push(asset.into());
self
}
pub fn assets(mut self, assets: impl IntoIterator<Item = impl Into<Asset>>) -> Self {
self.assets.extend(assets.into_iter().map(Into::into));
self
}
pub fn attachment(mut self, attachment: impl Into<NoteAttachment>) -> Self {
self.attachments.push(attachment.into());
self
}
pub fn attachments(
mut self,
attachments: impl IntoIterator<Item = impl Into<NoteAttachment>>,
) -> Self {
self.attachments.extend(attachments.into_iter().map(Into::into));
self
}
}
impl<S: p2ide_note_builder::State> P2ideNoteBuilder<S>
where
S::SerialNumber: p2ide_note_builder::IsUnset,
{
pub fn generate_serial_number(
self,
rng: &mut impl FeltRng,
) -> P2ideNoteBuilder<p2ide_note_builder::SetSerialNumber<S>> {
self.serial_number(rng.draw_word())
}
}
impl From<P2ideNote> for Note {
fn from(note: P2ideNote) -> Self {
let recipient = note.storage.into_recipient(note.serial_number);
let tag = NoteTag::with_account_target(note.storage.target());
let metadata = PartialNoteMetadata::new(note.sender, note.note_type).with_tag(tag);
Note::with_attachments(note.assets, metadata, recipient, note.attachments)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct P2ideNoteStorage {
reclaimer: AccountId,
target: AccountId,
reclaim_height: Option<BlockNumber>,
timelock_height: Option<BlockNumber>,
}
impl P2ideNoteStorage {
pub const NUM_ITEMS: usize = 6;
const RECLAIMER_SUFFIX_IDX: usize = 0;
const RECLAIMER_PREFIX_IDX: usize = 1;
const TARGET_SUFFIX_IDX: usize = 2;
const TARGET_PREFIX_IDX: usize = 3;
const RECLAIM_HEIGHT_IDX: usize = 4;
const TIMELOCK_HEIGHT_IDX: usize = 5;
pub fn new(
reclaimer: AccountId,
target: AccountId,
reclaim_height: Option<BlockNumber>,
timelock_height: Option<BlockNumber>,
) -> Self {
Self {
reclaimer,
target,
reclaim_height,
timelock_height,
}
}
pub fn into_recipient(self, serial_num: Word) -> NoteRecipient {
NoteRecipient::new(serial_num, P2ideNote::script(), self.into())
}
pub fn reclaimer(&self) -> AccountId {
self.reclaimer
}
pub fn target(&self) -> AccountId {
self.target
}
pub fn reclaim_height(&self) -> Option<BlockNumber> {
self.reclaim_height
}
pub fn timelock_height(&self) -> Option<BlockNumber> {
self.timelock_height
}
}
impl From<P2ideNoteStorage> for NoteStorage {
fn from(storage: P2ideNoteStorage) -> Self {
let reclaim = storage.reclaim_height.map_or(Felt::ZERO, Felt::from);
let timelock = storage.timelock_height.map_or(Felt::ZERO, Felt::from);
NoteStorage::new(vec![
storage.reclaimer.suffix(),
storage.reclaimer.prefix().as_felt(),
storage.target.suffix(),
storage.target.prefix().as_felt(),
reclaim,
timelock,
])
.expect("number of storage items should not exceed max storage items")
}
}
impl TryFrom<&[Felt]> for P2ideNoteStorage {
type Error = NoteError;
fn try_from(note_storage: &[Felt]) -> Result<Self, Self::Error> {
if note_storage.len() != P2ideNote::NUM_STORAGE_ITEMS {
return Err(NoteError::InvalidNoteStorageLength {
expected: P2ideNote::NUM_STORAGE_ITEMS,
actual: note_storage.len(),
});
}
let reclaimer = AccountId::try_from_elements(
note_storage[Self::RECLAIMER_SUFFIX_IDX],
note_storage[Self::RECLAIMER_PREFIX_IDX],
)
.map_err(|err| {
NoteError::other_with_source("failed to create reclaimer account id", err)
})?;
let target = AccountId::try_from_elements(
note_storage[Self::TARGET_SUFFIX_IDX],
note_storage[Self::TARGET_PREFIX_IDX],
)
.map_err(|err| NoteError::other_with_source("failed to create target account id", err))?;
let reclaim_height = decode_block_height(
note_storage[Self::RECLAIM_HEIGHT_IDX],
"invalid reclaim height in note storage",
)?;
let timelock_height = decode_block_height(
note_storage[Self::TIMELOCK_HEIGHT_IDX],
"invalid timelock height in note storage",
)?;
Ok(Self {
reclaimer,
target,
reclaim_height,
timelock_height,
})
}
}
fn decode_block_height(
item: Felt,
error_msg: &'static str,
) -> Result<Option<BlockNumber>, NoteError> {
if item == Felt::ZERO {
return Ok(None);
}
let height: u32 = item
.as_canonical_u64()
.try_into()
.map_err(|e| NoteError::other_with_source(error_msg, e))?;
Ok(Some(BlockNumber::from(height)))
}
#[cfg(test)]
mod tests {
use assert_matches::assert_matches;
use miden_protocol::account::{AccountId, AccountType};
use miden_protocol::asset::FungibleAsset;
use miden_protocol::block::BlockNumber;
use miden_protocol::crypto::rand::RandomCoin;
use miden_protocol::errors::NoteError;
use miden_protocol::{Felt, Word};
use super::*;
const INVALID_ID_SUFFIX: Felt = Felt::new_unchecked(999);
const INVALID_ID_PREFIX: Felt = Felt::new_unchecked(888);
fn dummy_account() -> AccountId {
AccountId::builder()
.account_type(AccountType::Private)
.build_with_seed([3u8; 32])
}
#[test]
fn try_from_valid_storage_with_all_fields_succeeds() {
let reclaimer = sender();
let target = dummy_account();
let storage = vec![
reclaimer.suffix(),
reclaimer.prefix().as_felt(),
target.suffix(),
target.prefix().as_felt(),
Felt::from(42u32),
Felt::from(100u32),
];
let decoded = P2ideNoteStorage::try_from(storage.as_slice())
.expect("valid P2IDE storage should decode");
assert_eq!(decoded.reclaimer(), reclaimer);
assert_eq!(decoded.target(), target);
assert_eq!(decoded.reclaim_height(), Some(BlockNumber::from(42u32)));
assert_eq!(decoded.timelock_height(), Some(BlockNumber::from(100u32)));
}
#[test]
fn try_from_zero_heights_map_to_none() {
let reclaimer = sender();
let target = dummy_account();
let storage = vec![
reclaimer.suffix(),
reclaimer.prefix().as_felt(),
target.suffix(),
target.prefix().as_felt(),
Felt::ZERO,
Felt::ZERO,
];
let decoded = P2ideNoteStorage::try_from(storage.as_slice()).unwrap();
assert_eq!(decoded.reclaim_height(), None);
assert_eq!(decoded.timelock_height(), None);
}
#[test]
fn try_from_invalid_length_fails() {
let storage = vec![Felt::ZERO; 3];
let err =
P2ideNoteStorage::try_from(storage.as_slice()).expect_err("wrong length must fail");
assert!(matches!(
err,
NoteError::InvalidNoteStorageLength {
expected: P2ideNote::NUM_STORAGE_ITEMS,
actual: 3
}
));
}
#[test]
fn try_from_invalid_reclaimer_fails() {
let target = dummy_account();
let storage = vec![
INVALID_ID_SUFFIX,
INVALID_ID_PREFIX,
target.suffix(),
target.prefix().as_felt(),
Felt::ZERO,
Felt::ZERO,
];
let err = P2ideNoteStorage::try_from(storage.as_slice())
.expect_err("invalid reclaimer encoding must fail");
assert_matches!(err, NoteError::Other { error_msg, source: Some(_), .. } => {
assert!(error_msg.contains("reclaimer"));
});
}
#[test]
fn try_from_invalid_target_fails() {
let reclaimer = sender();
let storage = vec![
reclaimer.suffix(),
reclaimer.prefix().as_felt(),
INVALID_ID_SUFFIX,
INVALID_ID_PREFIX,
Felt::ZERO,
Felt::ZERO,
];
let err = P2ideNoteStorage::try_from(storage.as_slice())
.expect_err("invalid target encoding must fail");
assert_matches!(err, NoteError::Other { error_msg, source: Some(_), .. } => {
assert!(error_msg.contains("target"));
});
}
#[test]
fn storage_round_trips_through_note_storage() {
let storage = P2ideNoteStorage::new(
sender(),
target(),
Some(BlockNumber::from(42u32)),
Some(BlockNumber::from(100u32)),
);
let encoded: NoteStorage = storage.into();
let decoded = P2ideNoteStorage::try_from(encoded.items()).unwrap();
assert_eq!(decoded, storage);
}
#[test]
fn zero_reclaim_height_means_reclaim_disabled() {
let storage = P2ideNoteStorage::new(sender(), target(), Some(BlockNumber::GENESIS), None);
let encoded: NoteStorage = storage.into();
let decoded = P2ideNoteStorage::try_from(encoded.items()).unwrap();
assert_eq!(decoded.reclaim_height(), None);
}
#[test]
fn try_from_reclaim_height_overflow_fails() {
let reclaimer = sender();
let target = dummy_account();
let overflow = Felt::new_unchecked(u64::from(u32::MAX) + 1);
let storage = vec![
reclaimer.suffix(),
reclaimer.prefix().as_felt(),
target.suffix(),
target.prefix().as_felt(),
overflow,
Felt::ZERO,
];
let err = P2ideNoteStorage::try_from(storage.as_slice())
.expect_err("overflow reclaim height must fail");
assert_matches!(err, NoteError::Other { error_msg, source: Some(_), .. } => {
assert!(error_msg.contains("reclaim height"));
});
}
#[test]
fn try_from_timelock_height_overflow_fails() {
let reclaimer = sender();
let target = dummy_account();
let overflow = Felt::new_unchecked(u64::from(u32::MAX) + 10);
let storage = vec![
reclaimer.suffix(),
reclaimer.prefix().as_felt(),
target.suffix(),
target.prefix().as_felt(),
Felt::ZERO,
overflow,
];
let err = P2ideNoteStorage::try_from(storage.as_slice())
.expect_err("overflow timelock height must fail");
assert_matches!(err, NoteError::Other { error_msg, source: Some(_), .. } => {
assert!(error_msg.contains("timelock height"));
});
}
fn sender() -> AccountId {
AccountId::builder()
.account_type(AccountType::Private)
.build_with_seed([1u8; 32])
}
fn target() -> AccountId {
AccountId::builder()
.account_type(AccountType::Private)
.build_with_seed([2u8; 32])
}
fn faucet_a() -> AccountId {
AccountId::builder()
.account_type(AccountType::Public)
.build_with_seed([3u8; 32])
}
fn faucet_b() -> AccountId {
AccountId::builder()
.account_type(AccountType::Public)
.build_with_seed([4u8; 32])
}
#[test]
fn builder_minimal_uses_defaults() {
let note = P2ideNote::builder()
.sender(sender())
.target(target())
.serial_number(Word::empty())
.asset(FungibleAsset::new(faucet_a(), 1).unwrap())
.build()
.unwrap();
assert_eq!(note.sender(), sender());
assert_eq!(note.target(), target());
assert_eq!(note.reclaimer(), sender());
assert_eq!(note.note_type(), NoteType::default());
assert_eq!(note.reclaim_height(), None);
assert_eq!(note.timelock_height(), None);
assert_eq!(note.assets().num_assets(), 1);
assert_eq!(note.attachments().num_attachments(), 0);
}
#[test]
fn builder_accumulates_assets() {
let mut rng = RandomCoin::new(Word::empty());
let note = P2ideNote::builder()
.sender(sender())
.target(target())
.asset(FungibleAsset::new(faucet_a(), 100).unwrap())
.assets([Asset::from(FungibleAsset::new(faucet_b(), 200).unwrap())])
.generate_serial_number(&mut rng)
.build()
.unwrap();
assert_eq!(note.assets().num_assets(), 2);
assert_ne!(note.serial_number(), Word::empty());
}
#[test]
fn builder_rejects_empty_assets() {
let err = P2ideNote::builder()
.sender(sender())
.target(target())
.serial_number(Word::empty())
.build()
.expect_err("a note without assets must be rejected");
assert_matches!(err, NoteError::Other { error_msg, .. } => {
assert!(error_msg.contains("note must contain at least one asset"))
});
}
#[test]
fn builder_sets_reclaim_and_timelock() {
let note = P2ideNote::builder()
.sender(sender())
.target(target())
.serial_number(Word::empty())
.asset(FungibleAsset::new(faucet_a(), 1).unwrap())
.reclaim_height(BlockNumber::from(42u32))
.timelock_height(BlockNumber::from(100u32))
.build()
.unwrap();
assert_eq!(note.reclaim_height(), Some(BlockNumber::from(42u32)));
assert_eq!(note.timelock_height(), Some(BlockNumber::from(100u32)));
}
#[test]
fn builder_explicit_reclaimer_differs_from_sender() {
let note = P2ideNote::builder()
.sender(sender())
.target(target())
.reclaimer(dummy_account())
.serial_number(Word::empty())
.asset(FungibleAsset::new(faucet_a(), 1).unwrap())
.build()
.unwrap();
assert_eq!(note.sender(), sender());
assert_eq!(note.reclaimer(), dummy_account());
assert_ne!(note.reclaimer(), note.sender());
let storage: NoteStorage = note.storage().into();
let decoded = P2ideNoteStorage::try_from(storage.items()).unwrap();
assert_eq!(decoded.reclaimer(), dummy_account());
assert_eq!(decoded.target(), target());
}
}