diff --git a/CHANGELOG.md b/CHANGELOG.md index 610f681cce..acc22cc39a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)). diff --git a/crates/miden-standards/src/note/execution_hint.rs b/crates/miden-standards/src/note/execution_hint.rs index 0714542738..a3b5005964 100644 --- a/crates/miden-standards/src/note/execution_hint.rs +++ b/crates/miden-standards/src/note/execution_hint.rs @@ -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 { @@ -126,7 +128,7 @@ impl NoteExecutionHint { pub fn can_be_consumed(&self, block_num: BlockNumber) -> Option { 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()) @@ -147,19 +149,21 @@ 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, } } } @@ -167,31 +171,30 @@ impl NoteExecutionHint { /// Converts a [`NoteExecutionHint`] into a [`Felt`] with the layout documented on the type. impl From 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 for NoteExecutionHint { - type Error = NoteError; - fn try_from(value: u64) -> Result { - 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 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 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)) } } @@ -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); } @@ -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] diff --git a/crates/miden-standards/src/note/network_account_target.rs b/crates/miden-standards/src/note/network_account_target.rs index 5571f93885..1281fc6c01 100644 --- a/crates/miden-standards/src/note/network_account_target.rs +++ b/crates/miden-standards/src/note/network_account_target.rs @@ -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}; @@ -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)) } } @@ -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), } @@ -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; @@ -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] diff --git a/crates/miden-testing/src/kernel_tests/tx/test_output_note.rs b/crates/miden-testing/src/kernel_tests/tx/test_output_note.rs index 11f2be3c99..afe94e7cc0 100644 --- a/crates/miden-testing/src/kernel_tests/tx/test_output_note.rs +++ b/crates/miden-testing/src/kernel_tests/tx/test_output_note.rs @@ -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()?;