Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@

### Fixes

- [BREAKING] `NetworkAccountTarget` decoding no longer discards the target account ID when the execution hint slot holds an unrecognized encoding ([#3811](https://github.com/0xMiden/protocol/pull/3811)).
- Fixed `AuthNetworkAccount` accepting empty fee-only transactions, which let callers drain the account's native fee-asset vault ([#3729](https://github.com/0xMiden/protocol/pull/3729)).
- [BREAKING] AggLayer bridge token registration now rejects keys owned by another faucet, and token-key cleanup verifies ownership before clearing a mapping ([#3754](https://github.com/0xMiden/protocol/pull/3754)).
- [BREAKING] AggLayer bridges now allow faucet deregistration while paused, so compromised faucets can be revoked without resuming claims and bridge-outs ([#3750](https://github.com/0xMiden/protocol/pull/3753)).
- Generated constant fee schedules now assign `FEE_SPONSORSHIP` an explicit zero fee, matching the fee-collection exemption while keeping the note allowlisted ([#3580](https://github.com/0xMiden/protocol/issues/3580)).
- Fixed `RoleBasedAccessControl` role administration becoming permanently unmanageable when a role's admin was delegated to a memberless role ([#3476](https://github.com/0xMiden/protocol/pull/3476)).
- [BREAKING] Bound the non-fungible MINT note to its faucet the same way the fungible one is bound: the note now stores the full asset and `non_fungible::mint_and_send` asserts the stored `ASSET_ID` against the asset it derives for the active faucet, unifying the two MINT note storage layouts and collapsing `MintNoteStorage` to `Private` / `Public` ([#3482](https://github.com/0xMiden/protocol/pull/3482)).
- Documented that `authority::assert_authorized` is a no-op under `Authority::AuthControlled` ([#3500](https://github.com/0xMiden/protocol/pull/3500)).
Expand Down
111 changes: 65 additions & 46 deletions crates/miden-standards/src/note/execution_hint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ pub enum NoteExecutionHint {
slot_len: u8,
slot_offset: u8,
},
/// An encoding that this version does not recognize, preserved verbatim.
Unknown(Felt),
}

impl NoteExecutionHint {
Expand Down Expand Up @@ -126,7 +128,7 @@ impl NoteExecutionHint {
pub fn can_be_consumed(&self, block_num: BlockNumber) -> Option<bool> {
let block_num = block_num.as_u32();
match self {
NoteExecutionHint::None => None,
NoteExecutionHint::None | NoteExecutionHint::Unknown(_) => None,
NoteExecutionHint::Always => Some(true),
NoteExecutionHint::AfterBlock { block_num: hint_block_num } => {
Some(block_num >= hint_block_num.as_u32())
Expand All @@ -147,51 +149,52 @@ impl NoteExecutionHint {
}
}

/// Encodes the [`NoteExecutionHint`] into an 8-bit tag and a 32-bit payload.
pub fn into_parts(&self) -> (u8, u32) {
/// Encodes the [`NoteExecutionHint`] into an 8-bit tag and a 32-bit payload, or `None` for
/// [`NoteExecutionHint::Unknown`], which by definition has no valid decomposition.
pub fn into_parts(&self) -> Option<(u8, u32)> {
match self {
NoteExecutionHint::None => (Self::NONE_TAG, 0),
NoteExecutionHint::Always => (Self::ALWAYS_TAG, 0),
NoteExecutionHint::None => Some((Self::NONE_TAG, 0)),
NoteExecutionHint::Always => Some((Self::ALWAYS_TAG, 0)),
NoteExecutionHint::AfterBlock { block_num } => {
(Self::AFTER_BLOCK_TAG, block_num.as_u32())
Some((Self::AFTER_BLOCK_TAG, block_num.as_u32()))
},
NoteExecutionHint::OnBlockSlot { round_len, slot_len, slot_offset } => {
let payload: u32 =
((*round_len as u32) << 16) | ((*slot_len as u32) << 8) | (*slot_offset as u32);
(Self::ON_BLOCK_SLOT_TAG, payload)
Some((Self::ON_BLOCK_SLOT_TAG, payload))
},
NoteExecutionHint::Unknown(_) => None,
}
}
}

/// Converts a [`NoteExecutionHint`] into a [`Felt`] with the layout documented on the type.
impl From<NoteExecutionHint> for Felt {
fn from(value: NoteExecutionHint) -> Self {
let int_representation: u64 = value.into();
Felt::new_unchecked(int_representation)
}
}

/// Tries to convert a `u64` into a [`NoteExecutionHint`] with the expected layout documented on the
/// type.
///
/// Note: The upper 24 bits are not enforced to be zero.
impl TryFrom<u64> for NoteExecutionHint {
type Error = NoteError;
fn try_from(value: u64) -> Result<Self, Self::Error> {
let tag = (value & 0b1111_1111) as u8;
// Shift the payload and cut off / ignore the upper 32 bits.
let payload = (value >> 8) as u32;

Self::from_parts(tag, payload)
match value {
NoteExecutionHint::Unknown(felt) => felt,
hint => {
let (tag, payload) =
hint.into_parts().expect("every hint but `Unknown` decomposes into parts");
// The composed value occupies the low 40 bits, so it is always canonical.
Felt::new_unchecked(((payload as u64) << 8) | (tag as u64))
},
}
}
}

/// Converts a [`NoteExecutionHint`] into a `u64` with the layout documented on the type.
impl From<NoteExecutionHint> for u64 {
fn from(value: NoteExecutionHint) -> Self {
let (tag, payload) = value.into_parts();
((payload as u64) << 8) | (tag as u64)
/// Converts a [`Felt`] into a [`NoteExecutionHint`] with the layout documented on the type.
impl From<Felt> for NoteExecutionHint {
fn from(value: Felt) -> Self {
let encoded = value.as_canonical_u64();
let tag = (encoded & 0b1111_1111) as u8;

// A felt with bits set above the documented layout does not encode a hint, so it must not
// truncate into one - that would lose those bits on re-encoding.
u32::try_from(encoded >> 8)
.ok()
.and_then(|payload| Self::from_parts(tag, payload).ok())
.unwrap_or(NoteExecutionHint::Unknown(value))
}
}

Expand All @@ -204,7 +207,7 @@ mod tests {
use super::*;

fn assert_hint_serde(note_execution_hint: NoteExecutionHint) {
let (tag, payload) = note_execution_hint.into_parts();
let (tag, payload) = note_execution_hint.into_parts().unwrap();
let deserialized = NoteExecutionHint::from_parts(tag, payload).unwrap();
assert_eq!(deserialized, note_execution_hint);
}
Expand All @@ -223,22 +226,38 @@ mod tests {

#[test]
fn test_encode_round_trip() {
let hint = NoteExecutionHint::after_block(15.into());
let hint_int: u64 = hint.into();
let decoded_hint: NoteExecutionHint = hint_int.try_into().unwrap();
assert_eq!(hint, decoded_hint);

let hint = NoteExecutionHint::OnBlockSlot {
round_len: 22,
slot_len: 33,
slot_offset: 44,
};
let hint_int: u64 = hint.into();
let decoded_hint: NoteExecutionHint = hint_int.try_into().unwrap();
assert_eq!(hint, decoded_hint);

let always_int: u64 = NoteExecutionHint::always().into();
assert_eq!(always_int, 1u64);
for hint in [
NoteExecutionHint::None,
NoteExecutionHint::Always,
NoteExecutionHint::after_block(15.into()),
NoteExecutionHint::OnBlockSlot {
round_len: 22,
slot_len: 33,
slot_offset: 44,
},
] {
let encoded = Felt::from(hint);
assert_eq!(NoteExecutionHint::from(encoded), hint);
}

assert_eq!(Felt::from(NoteExecutionHint::always()).as_canonical_u64(), 1);
}

/// A felt that does not encode a recognized hint decodes as `Unknown`.
#[test]
fn unknown_hint_round_trip() {
// A tag above the highest known one, a non-zero payload on a tag that requires an empty
// one, a non-zero remainder on the `OnBlockSlot` payload, and a felt with bits set above
// the documented 40-bit layout.
for encoded in [7u64, (1 << 8) | 1, (1 << 32) | 3, 1 << 40] {
let encoded = Felt::new(encoded).unwrap();
let hint = NoteExecutionHint::from(encoded);

assert_eq!(hint, NoteExecutionHint::Unknown(encoded));
assert_eq!(hint.into_parts(), None);
assert_eq!(hint.can_be_consumed(100.into()), None);
assert_eq!(Felt::from(hint), encoded);
}
}

#[test]
Expand Down
37 changes: 30 additions & 7 deletions crates/miden-standards/src/note/network_account_target.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use alloc::vec::Vec;

use miden_protocol::Word;
use miden_protocol::account::AccountId;
use miden_protocol::errors::{AccountIdError, NoteError};
use miden_protocol::errors::AccountIdError;
use miden_protocol::note::{NoteAttachment, NoteAttachmentScheme, NoteAttachments, NoteType};

use crate::note::{NoteExecutionHint, StandardNoteAttachment};
Expand Down Expand Up @@ -200,10 +200,7 @@ impl TryFrom<&NoteAttachment> for NetworkAccountTarget {
let target_id = AccountId::try_from_elements(id_suffix, id_prefix)
.map_err(NetworkAccountTargetError::DecodeTargetId)?;

let exec_hint = NoteExecutionHint::try_from(exec_hint.as_canonical_u64())
.map_err(NetworkAccountTargetError::DecodeExecutionHint)?;

NetworkAccountTarget::new(target_id, exec_hint)
NetworkAccountTarget::new(target_id, NoteExecutionHint::from(exec_hint))
}
}

Expand All @@ -227,8 +224,6 @@ pub enum NetworkAccountTargetError {
AttachmentContentNumWordsMismatch(u16),
#[error("failed to decode target account ID")]
DecodeTargetId(#[source] AccountIdError),
#[error("failed to decode execution hint")]
DecodeExecutionHint(#[source] NoteError),
#[error("network note must be public, but was {0:?}")]
NoteNotPublic(NoteType),
}
Expand All @@ -241,6 +236,7 @@ mod tests {
use alloc::vec;

use assert_matches::assert_matches;
use miden_protocol::Felt;
use miden_protocol::account::AccountType;
use miden_protocol::testing::account_id::AccountIdBuilder;

Expand All @@ -264,6 +260,33 @@ mod tests {
Ok(())
}

/// An execution hint encoding this version does not recognize must not hide the target
/// account, since the on-chain check discards the hint felt entirely.
#[test]
fn unrecognized_execution_hint_preserves_target_id() -> anyhow::Result<()> {
let target_id = public_account_id();

// Tag 7 is above the highest known tag, and a non-zero payload on the `Always` tag is
// rejected by `NoteExecutionHint::from_parts`.
for raw_hint in [7u64, (1 << 8) | 1] {
let raw_hint = Felt::new(raw_hint)?;
let mut word = Word::empty();
word[0] = target_id.suffix();
word[1] = target_id.prefix().as_felt();
word[2] = raw_hint;
let attachment =
NoteAttachment::with_word(NetworkAccountTarget::ATTACHMENT_SCHEME, word);

let target = NetworkAccountTarget::try_from(&attachment)?;
assert_eq!(target.target_id(), target_id);
assert_eq!(target.execution_hint(), NoteExecutionHint::Unknown(raw_hint));
// Re-encoding is lossless, so the note commitment is unaffected.
assert_eq!(NoteAttachment::from(target), attachment);
}

Ok(())
}

/// A caller-supplied target for the same account is kept as-is, so its execution hint survives
/// and no duplicate attachment is added.
#[test]
Expand Down
21 changes: 21 additions & 0 deletions crates/miden-testing/src/kernel_tests/tx/test_output_note.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1580,6 +1580,27 @@ async fn test_network_note() -> anyhow::Result<()> {
let try_from_note = AccountTargetNetworkNote::try_from(valid_note)?;
assert_eq!(try_from_note.target_account_id(), target_id);

// --- Unrecognized execution hint: still a network note ---
// The on-chain targeting path discards the hint felt, so a hint encoding this version does not
// recognize must not hide the note from routing.
let raw_hint = Felt::new(7)?;
let mut unknown_hint_word = Word::empty();
unknown_hint_word[0] = target_id.suffix();
unknown_hint_word[1] = target_id.prefix().as_felt();
unknown_hint_word[2] = raw_hint;
let unknown_hint_note = NoteBuilder::new(sender.id(), &mut rng)
.note_type(NoteType::Public)
.attachment(NoteAttachment::with_word(
NetworkAccountTarget::ATTACHMENT_SCHEME,
unknown_hint_word,
))
.build()?;

assert!(unknown_hint_note.is_network_note());
let unknown_hint_note = unknown_hint_note.into_account_target_network_note()?;
assert_eq!(unknown_hint_note.target_account_id(), target_id);
assert_eq!(unknown_hint_note.execution_hint(), NoteExecutionHint::Unknown(raw_hint));

// --- Invalid: note with default (empty) attachment ---
let non_network_note =
NoteBuilder::new(sender.id(), &mut rng).note_type(NoteType::Public).build()?;
Expand Down
Loading