diff --git a/noir-projects/aztec-nr/aztec/src/authwit/account.nr b/noir-projects/aztec-nr/aztec/src/authwit/account.nr index ba8cec3d4fc7..aa07575d888a 100644 --- a/noir-projects/aztec-nr/aztec/src/authwit/account.nr +++ b/noir-projects/aztec-nr/aztec/src/authwit/account.nr @@ -56,7 +56,13 @@ impl AccountActions<&mut PrivateContext> { pub fn entrypoint(self, app_payload: AppPayload, fee_payment_method: u8, cancellable: bool) { let valid_fn = self.is_valid_impl; - assert(valid_fn(self.context, app_payload.hash())); + let message_hash = compute_authwit_message_hash( + self.context.this_address(), + self.context.chain_id(), + self.context.version(), + app_payload.hash(), + ); + assert(valid_fn(self.context, message_hash)); if fee_payment_method == AccountFeePaymentMethodOptions.PREEXISTING_FEE_JUICE { self.context.set_as_fee_payer(); diff --git a/noir-projects/aztec-nr/aztec/src/messages/encryption/aes128.nr b/noir-projects/aztec-nr/aztec/src/messages/encryption/aes128.nr index 4197226eb618..46b6a7a361d9 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/encryption/aes128.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/encryption/aes128.nr @@ -20,7 +20,7 @@ use crate::{ get_arr_of_size__message_bytes__from_PT, get_arr_of_size__message_bytes_padding__from_PT, }, }, - oracle::{aes128_decrypt::aes128_decrypt_oracle, random::random, shared_secret::get_shared_secret}, + oracle::{aes128_decrypt::aes128_decrypt, random::random, shared_secret::get_shared_secret}, utils::{ array, conversion::{ @@ -425,7 +425,7 @@ impl MessageEncryption for AES128 { BoundedVec::::from_array(header_ciphertext); // Decrypt header - let header_plaintext = aes128_decrypt_oracle(header_ciphertext_bvec, header_iv, header_sym_key); + let header_plaintext = aes128_decrypt(header_ciphertext_bvec, header_iv, header_sym_key); // Extract ciphertext length from header (2 bytes, big-endian) extract_ciphertext_length(header_plaintext) @@ -439,7 +439,7 @@ impl MessageEncryption for AES128 { BoundedVec::from_parts(ciphertext_with_padding, ciphertext_length); // Decrypt main ciphertext and return it - let plaintext_bytes = aes128_decrypt_oracle(ciphertext, body_iv, body_sym_key); + let plaintext_bytes = aes128_decrypt(ciphertext, body_iv, body_sym_key); // Convert bytes back to fields (32 bytes per field). Returns None if the actual bytes are // not valid. diff --git a/noir-projects/aztec-nr/aztec/src/oracle/aes128_decrypt.nr b/noir-projects/aztec-nr/aztec/src/oracle/aes128_decrypt.nr index 7874765a31f7..0569dd438dc1 100644 --- a/noir-projects/aztec-nr/aztec/src/oracle/aes128_decrypt.nr +++ b/noir-projects/aztec-nr/aztec/src/oracle/aes128_decrypt.nr @@ -1,3 +1,12 @@ +use crate::utils::array::assert_bounded_vec_trimmed; + +#[oracle(aztec_utl_aes128Decrypt)] +unconstrained fn aes128_decrypt_oracle( + ciphertext: BoundedVec, + iv: [u8; 16], + sym_key: [u8; 16], +) -> BoundedVec {} + /// Decrypts a ciphertext, using AES128. /// /// Returns a BoundedVec containing the plaintext. @@ -8,12 +17,15 @@ /// Note that we accept ciphertext as a BoundedVec, not as an array. This is because this function is typically used /// when processing logs and at that point we don't have a comptime information about the length of the ciphertext as /// the log is not specific to any individual note. -#[oracle(aztec_utl_aes128Decrypt)] -pub unconstrained fn aes128_decrypt_oracle( +pub unconstrained fn aes128_decrypt( ciphertext: BoundedVec, iv: [u8; 16], sym_key: [u8; 16], -) -> BoundedVec {} +) -> BoundedVec { + let result = aes128_decrypt_oracle(ciphertext, iv, sym_key); + assert_bounded_vec_trimmed(result); + result +} mod test { use crate::{ @@ -21,7 +33,7 @@ mod test { utils::{array::subarray::subarray, point::point_from_x_coord}, }; use crate::test::helpers::test_environment::TestEnvironment; - use super::aes128_decrypt_oracle; + use super::aes128_decrypt; use poseidon::poseidon2::Poseidon2; use std::aes128::aes128_encrypt; @@ -49,7 +61,7 @@ mod test { // ciphertext length is fixed. But we do it anyway to not have to have duplicate oracles. let ciphertext_bvec = BoundedVec::::from_array(ciphertext); - let received_plaintext = aes128_decrypt_oracle(ciphertext_bvec, iv, sym_key); + let received_plaintext = aes128_decrypt(ciphertext_bvec, iv, sym_key); assert_eq(received_plaintext.len(), TEST_PLAINTEXT_LENGTH); assert_eq(received_plaintext.max_len(), TEST_CIPHERTEXT_LENGTH); @@ -123,7 +135,7 @@ mod test { // We need to convert the array to a BoundedVec because the oracle expects a BoundedVec as it's designed to // work with logs of unknown length. let ciphertext_bvec = BoundedVec::::from_array(ciphertext); - let received_plaintext = aes128_decrypt_oracle(ciphertext_bvec, iv, bad_sym_key); + let received_plaintext = aes128_decrypt(ciphertext_bvec, iv, bad_sym_key); let extracted_mac_as_bytes: [u8; TEST_MAC_LENGTH] = subarray(received_plaintext.storage(), TEST_PLAINTEXT_LENGTH); diff --git a/noir-projects/aztec-nr/aztec/src/oracle/block_header.nr b/noir-projects/aztec-nr/aztec/src/oracle/block_header.nr index d1f7b81243e2..b213b73e4a7b 100644 --- a/noir-projects/aztec-nr/aztec/src/oracle/block_header.nr +++ b/noir-projects/aztec-nr/aztec/src/oracle/block_header.nr @@ -75,11 +75,107 @@ fn constrain_get_block_header_at_internal( } mod test { + use crate::protocol::traits::Hash; use crate::test::helpers::test_environment::TestEnvironment; - use super::{constrain_get_block_header_at_internal, get_block_header_at_internal}; + use super::{constrain_get_block_header_at_internal, get_block_header_at, get_block_header_at_internal}; - #[test(should_fail_with = "Proving membership of a block in archive failed")] - unconstrained fn fetching_header_with_mismatched_block_number_should_fail() { + #[test] + unconstrained fn fetching_earliest_block_header_succeeds() { + let env = TestEnvironment::new(); + + env.mine_block(); + env.mine_block(); + env.mine_block(); + env.mine_block(); + + env.private_context(|context| { + let anchor_block_header = context.anchor_block_header; + + let header = get_block_header_at_internal(1); + constrain_get_block_header_at_internal(header, 1, anchor_block_header); + + assert_eq(header.block_number(), 1); + }); + } + + #[test] + unconstrained fn fetching_past_block_header_succeeds() { + let env = TestEnvironment::new(); + + env.mine_block(); + env.mine_block(); + env.mine_block(); + env.mine_block(); + + env.private_context(|context| { + let anchor_block_header = context.anchor_block_header; + let target_block_number = anchor_block_header.block_number() - 2; + + let header = get_block_header_at_internal(target_block_number); + constrain_get_block_header_at_internal(header, target_block_number, anchor_block_header); + + assert_eq(header.block_number(), target_block_number); + }); + } + + #[test] + unconstrained fn fetching_header_immediately_before_anchor_succeeds() { + let env = TestEnvironment::new(); + + env.mine_block(); + env.mine_block(); + env.mine_block(); + env.mine_block(); + + // Block N-1 is the boundary case: last_archive covers exactly up to block N-1. + env.private_context(|context| { + let anchor_block_header = context.anchor_block_header; + let target_block_number = anchor_block_header.block_number() - 1; + + let header = get_block_header_at_internal(target_block_number); + constrain_get_block_header_at_internal(header, target_block_number, anchor_block_header); + + assert_eq(header.block_number(), target_block_number); + }); + } + + #[test] + unconstrained fn fetching_anchor_block_header_works() { + let env = TestEnvironment::new(); + + env.mine_block(); + env.mine_block(); + env.mine_block(); + env.mine_block(); + + env.private_context(|context| { + let anchor_block_number = context.anchor_block_header.block_number(); + + let header = get_block_header_at(anchor_block_number, *context); + + assert_eq(header.block_number(), anchor_block_number); + assert_eq(header.hash(), context.anchor_block_header.hash()); + }); + } + + #[test(should_fail_with = "Last archive block number is smaller than the block number")] + unconstrained fn fetching_future_block_header_fails() { + let env = TestEnvironment::new(); + + env.mine_block(); + env.mine_block(); + env.mine_block(); + env.mine_block(); + + env.private_context(|context| { + let anchor_block_number = context.anchor_block_header.block_number(); + + let _header = get_block_header_at(anchor_block_number + 1, *context); + }); + } + + #[test(should_fail_with = "Block number provided is not the same as the block number from the header hint")] + unconstrained fn fetching_header_with_mismatched_block_number_fails() { let env = TestEnvironment::new(); env.mine_block(); diff --git a/noir-projects/aztec-nr/aztec/src/oracle/notes.nr b/noir-projects/aztec-nr/aztec/src/oracle/notes.nr index 1975fbabb999..bb5020cd5612 100644 --- a/noir-projects/aztec-nr/aztec/src/oracle/notes.nr +++ b/noir-projects/aztec-nr/aztec/src/oracle/notes.nr @@ -1,4 +1,5 @@ use crate::note::{HintedNote, note_interface::NoteType}; +use crate::utils::array::assert_bounded_vec_trimmed; use crate::protocol::{address::AztecAddress, traits::Packable}; @@ -142,6 +143,7 @@ where MaxNotes, as Packable>::N, ); + assert_bounded_vec_trimmed(packed_hinted_notes); let mut notes = BoundedVec::<_, MaxNotes>::new(); for i in 0..packed_hinted_notes.len() { diff --git a/noir-projects/aztec-nr/aztec/src/utils/array/assert_trimmed.nr b/noir-projects/aztec-nr/aztec/src/utils/array/assert_trimmed.nr new file mode 100644 index 000000000000..784a51c4f3ba --- /dev/null +++ b/noir-projects/aztec-nr/aztec/src/utils/array/assert_trimmed.nr @@ -0,0 +1,66 @@ +/// Asserts that elements past `len()` in a `BoundedVec` are zeroed. +/// +/// Oracle functions may return `BoundedVec` values with dirty trailing storage (non-zero elements past `len()`). +/// This is problematic because `BoundedVec`'s `Eq` implementation and other operations assume trailing elements +/// are zeroed. +/// +/// This function should be called on any `BoundedVec` obtained from an oracle to guard against malformed data. +/// +/// TODO(https://github.com/noir-lang/noir/issues/4218): Remove once Noir natively validates `BoundedVec` returned +/// from unconstrained functions. +pub(crate) unconstrained fn assert_bounded_vec_trimmed(vec: BoundedVec) +where + T: Eq, +{ + let storage = vec.storage(); + let len = vec.len(); + for i in 0..MaxLen { + if i >= len { + assert_eq(storage[i], std::mem::zeroed(), "BoundedVec has non-zero trailing elements"); + } + } +} + +mod test { + use super::assert_bounded_vec_trimmed; + + #[test] + unconstrained fn trimmed_empty_vec() { + let vec: BoundedVec = BoundedVec::new(); + assert_bounded_vec_trimmed(vec); + } + + #[test] + unconstrained fn trimmed_full_vec() { + let vec = BoundedVec::::from_array([1, 2, 3]); + assert_bounded_vec_trimmed(vec); + } + + #[test] + unconstrained fn trimmed_partial_vec() { + let vec = BoundedVec::::from_array([1, 2, 3]); + assert_bounded_vec_trimmed(vec); + } + + #[test(should_fail_with = "BoundedVec has non-zero trailing elements")] + unconstrained fn dirty_trailing_element_fails() { + let mut vec = BoundedVec::::from_array([1]); + // We use the unchecked setter to write past the length, knowingly breaking the invariant. + vec.set_unchecked(1, 42); + assert_bounded_vec_trimmed(vec); + } + + #[test(should_fail_with = "BoundedVec has non-zero trailing elements")] + unconstrained fn dirty_last_element_fails() { + let mut vec = BoundedVec::::from_array([1, 2]); + vec.set_unchecked(2, 99); + assert_bounded_vec_trimmed(vec); + } + + #[test] + unconstrained fn trimmed_array_elements() { + // Test with array element type (like get_notes_oracle returns BoundedVec<[Field; N], MaxNotes>). + let vec = BoundedVec::<[Field; 2], 3>::from_array([[1, 2], [3, 4]]); + assert_bounded_vec_trimmed(vec); + } +} diff --git a/noir-projects/aztec-nr/aztec/src/utils/array/mod.nr b/noir-projects/aztec-nr/aztec/src/utils/array/mod.nr index 52bf6c799cc0..05a18cec68c7 100644 --- a/noir-projects/aztec-nr/aztec/src/utils/array/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/utils/array/mod.nr @@ -1,9 +1,11 @@ pub mod append; +pub mod assert_trimmed; pub mod collapse; pub mod subarray; pub mod subbvec; pub use append::append; +pub(crate) use assert_trimmed::assert_bounded_vec_trimmed; pub use collapse::collapse; pub use subarray::subarray; pub use subbvec::subbvec; diff --git a/noir-projects/noir-contracts/contracts/app/app_subscription_contract/src/subscription_note.nr b/noir-projects/noir-contracts/contracts/app/app_subscription_contract/src/subscription_note.nr index e177788bef2c..8da44be3e1bd 100644 --- a/noir-projects/noir-contracts/contracts/app/app_subscription_contract/src/subscription_note.nr +++ b/noir-projects/noir-contracts/contracts/app/app_subscription_contract/src/subscription_note.nr @@ -1,8 +1,50 @@ use aztec::{macros::notes::note, protocol::traits::Packable}; -#[derive(Eq, Packable)] +#[derive(Eq)] #[note] pub struct SubscriptionNote { pub expiry_block_number: u32, pub remaining_txs: u32, } + +impl Packable for SubscriptionNote { + let N: u32 = 1; + + fn pack(self) -> [Field; Self::N] { + [(self.expiry_block_number as Field) * 2.pow_32(32) + (self.remaining_txs as Field)] + } + + fn unpack(packed: [Field; Self::N]) -> Self { + let remaining_txs = packed[0] as u32; + let expiry_block_number = ((packed[0] - remaining_txs as Field) / 2.pow_32(32)) as u32; + Self { expiry_block_number, remaining_txs } + } +} + +mod test { + use super::{Packable, SubscriptionNote}; + + #[test] + fn test_pack_unpack_subscription_note() { + let note = SubscriptionNote { expiry_block_number: 1000, remaining_txs: 5 }; + let unpacked = SubscriptionNote::unpack(note.pack()); + assert_eq(unpacked.expiry_block_number, note.expiry_block_number); + assert_eq(unpacked.remaining_txs, note.remaining_txs); + } + + #[test] + fn test_pack_unpack_subscription_note_zeros() { + let note = SubscriptionNote { expiry_block_number: 0, remaining_txs: 0 }; + let unpacked = SubscriptionNote::unpack(note.pack()); + assert_eq(unpacked.expiry_block_number, 0); + assert_eq(unpacked.remaining_txs, 0); + } + + #[test] + fn test_pack_unpack_subscription_note_max() { + let note = SubscriptionNote { expiry_block_number: 0xffffffff, remaining_txs: 0xffffffff }; + let unpacked = SubscriptionNote::unpack(note.pack()); + assert_eq(unpacked.expiry_block_number, note.expiry_block_number); + assert_eq(unpacked.remaining_txs, note.remaining_txs); + } +} diff --git a/noir-projects/noir-contracts/contracts/app/card_game_contract/src/cards.nr b/noir-projects/noir-contracts/contracts/app/card_game_contract/src/cards.nr index 7c20480544be..d7cb22c0edfe 100644 --- a/noir-projects/noir-contracts/contracts/app/card_game_contract/src/cards.nr +++ b/noir-projects/noir-contracts/contracts/app/card_game_contract/src/cards.nr @@ -16,13 +16,27 @@ use aztec::{ use field_note::FieldNote; use std::meta::derive; -#[derive(Packable, Serialize)] +#[derive(Serialize)] pub struct Card { // We use u32s since u16s are unsupported pub strength: u32, pub points: u32, } +impl Packable for Card { + let N: u32 = 1; + + fn pack(self) -> [Field; Self::N] { + [(self.strength as Field) * 2.pow_32(32) + (self.points as Field)] + } + + fn unpack(packed: [Field; Self::N]) -> Self { + let points = packed[0] as u32; + let strength = ((packed[0] - points as Field) / 2.pow_32(32)) as u32; + Self { strength, points } + } +} + impl FromField for Card { fn from_field(field: Field) -> Card { let value_bytes: [u8; 32] = field.to_le_bytes(); @@ -38,11 +52,39 @@ impl ToField for Card { } } -#[test] -fn test_to_from_field() { - let field = 1234567890; - let card = Card::from_field(field); - assert(card.to_field() == field); +mod test { + use super::{Card, FromField, Packable, ToField}; + + #[test] + fn test_to_from_field() { + let field = 1234567890; + let card = Card::from_field(field); + assert(card.to_field() == field); + } + + #[test] + fn test_pack_unpack_card() { + let card = Card { strength: 42, points: 100 }; + let unpacked = Card::unpack(card.pack()); + assert_eq(unpacked.strength, card.strength); + assert_eq(unpacked.points, card.points); + } + + #[test] + fn test_pack_unpack_card_zeros() { + let card = Card { strength: 0, points: 0 }; + let unpacked = Card::unpack(card.pack()); + assert_eq(unpacked.strength, 0); + assert_eq(unpacked.points, 0); + } + + #[test] + fn test_pack_unpack_card_max() { + let card = Card { strength: 0xffffffff, points: 0xffffffff }; + let unpacked = Card::unpack(card.pack()); + assert_eq(unpacked.strength, card.strength); + assert_eq(unpacked.points, card.points); + } } pub struct CardNote { diff --git a/noir-projects/noir-contracts/contracts/app/card_game_contract/src/game.nr b/noir-projects/noir-contracts/contracts/app/card_game_contract/src/game.nr index 7d8cb6721a15..924f89e5417b 100644 --- a/noir-projects/noir-contracts/contracts/app/card_game_contract/src/game.nr +++ b/noir-projects/noir-contracts/contracts/app/card_game_contract/src/game.nr @@ -1,17 +1,35 @@ use crate::cards::Card; -use aztec::protocol::{address::AztecAddress, traits::{Deserialize, Packable}}; +use aztec::protocol::{address::AztecAddress, traits::{Deserialize, FromField, Packable, ToField}}; use std::meta::derive; global NUMBER_OF_PLAYERS: u32 = 2; global NUMBER_OF_CARDS_DECK: u32 = 2; -#[derive(Deserialize, Eq, Packable)] +#[derive(Deserialize, Eq)] pub struct PlayerEntry { pub address: AztecAddress, pub deck_strength: u32, pub points: u64, } +impl Packable for PlayerEntry { + let N: u32 = 2; + + fn pack(self) -> [Field; Self::N] { + [ + self.address.to_field(), + (self.deck_strength as Field) * 2.pow_32(64) + (self.points as Field), + ] + } + + fn unpack(packed: [Field; Self::N]) -> Self { + let address = AztecAddress::from_field(packed[0]); + let points = packed[1] as u64; + let deck_strength = ((packed[1] - points as Field) / 2.pow_32(64)) as u32; + Self { address, deck_strength, points } + } +} + impl PlayerEntry { pub fn is_initialized(self) -> bool { !self.address.is_zero() @@ -20,7 +38,6 @@ impl PlayerEntry { pub global PLAYABLE_CARDS: u32 = 4; -#[derive(Packable)] pub struct Game { players: [PlayerEntry; NUMBER_OF_PLAYERS], pub rounds_cards: [Card; PLAYABLE_CARDS], @@ -31,6 +48,65 @@ pub struct Game { current_round: u32, } +// Constants used just by the implementation of Packable +global PLAYERS_PACKED_LEN: u32 = NUMBER_OF_PLAYERS * ::N; +global CARDS_PACKED_LEN: u32 = PLAYABLE_CARDS * ::N; +global SCALARS_OFFSET: u32 = PLAYERS_PACKED_LEN + CARDS_PACKED_LEN; + +impl Packable for Game { + let N: u32 = PLAYERS_PACKED_LEN + CARDS_PACKED_LEN + 1; + + fn pack(self) -> [Field; Self::N] { + let mut result = [0; Self::N]; + + let players_packed = self.players.pack(); + for i in 0..PLAYERS_PACKED_LEN { + result[i] = players_packed[i]; + } + + let cards_packed = self.rounds_cards.pack(); + for i in 0..CARDS_PACKED_LEN { + result[PLAYERS_PACKED_LEN + i] = cards_packed[i]; + } + + // Layout: [ current_round: u32 | current_player: u32 | claimed: 1b | finished: 1b | started: 1b ] + result[SCALARS_OFFSET] = (self.started as Field) + + (self.finished as Field) * 2.pow_32(1) + + (self.claimed as Field) * 2.pow_32(2) + + (self.current_player as Field) * 2.pow_32(3) + + (self.current_round as Field) * 2.pow_32(35); + + result + } + + fn unpack(packed: [Field; Self::N]) -> Self { + let mut players_packed = [0; PLAYERS_PACKED_LEN]; + for i in 0..PLAYERS_PACKED_LEN { + players_packed[i] = packed[i]; + } + let players = <[PlayerEntry; NUMBER_OF_PLAYERS]>::unpack(players_packed); + + let mut cards_packed = [0; CARDS_PACKED_LEN]; + for i in 0..CARDS_PACKED_LEN { + cards_packed[i] = packed[PLAYERS_PACKED_LEN + i]; + } + let rounds_cards = <[Card; PLAYABLE_CARDS]>::unpack(cards_packed); + + let mut tmp = packed[SCALARS_OFFSET]; + let started = (tmp as u1) != 0; + tmp = (tmp - started as Field) / 2.pow_32(1); + let finished = (tmp as u1) != 0; + tmp = (tmp - finished as Field) / 2.pow_32(1); + let claimed = (tmp as u1) != 0; + tmp = (tmp - claimed as Field) / 2.pow_32(1); + let current_player = tmp as u32; + tmp = (tmp - current_player as Field) / 2.pow_32(32); + let current_round = tmp as u32; + + Self { players, rounds_cards, started, finished, claimed, current_player, current_round } + } +} + impl Game { pub fn add_player(&mut self, player_entry: PlayerEntry) -> bool { let mut added = false; @@ -116,3 +192,106 @@ impl Game { } } } + +mod test { + use crate::cards::Card; + use super::{AztecAddress, FromField, Game, Packable, PlayerEntry}; + + #[test] + fn test_pack_unpack_player_entry() { + let entry = PlayerEntry { + address: AztecAddress::from_field(0xdeadbeef), + deck_strength: 999, + points: 12345678, + }; + let unpacked = PlayerEntry::unpack(entry.pack()); + assert(unpacked.address.eq(entry.address)); + assert_eq(unpacked.deck_strength, entry.deck_strength); + assert_eq(unpacked.points, entry.points); + } + + #[test] + fn test_pack_unpack_player_entry_zeros() { + let entry = + PlayerEntry { address: AztecAddress::from_field(0), deck_strength: 0, points: 0 }; + let unpacked = PlayerEntry::unpack(entry.pack()); + assert(unpacked.address.eq(entry.address)); + assert_eq(unpacked.deck_strength, 0); + assert_eq(unpacked.points, 0); + } + + #[test] + fn test_pack_unpack_game() { + let game = Game { + players: [ + PlayerEntry { + address: AztecAddress::from_field(1), + deck_strength: 100, + points: 50, + }, + PlayerEntry { + address: AztecAddress::from_field(2), + deck_strength: 200, + points: 75, + }, + ], + rounds_cards: [ + Card { strength: 10, points: 5 }, + Card { strength: 20, points: 15 }, + Card { strength: 30, points: 25 }, + Card { strength: 40, points: 35 }, + ], + started: true, + finished: false, + claimed: true, + current_player: 1, + current_round: 1, + }; + let unpacked = Game::unpack(game.pack()); + + assert(unpacked.players[0].address.eq(game.players[0].address)); + assert_eq(unpacked.players[0].deck_strength, 100); + assert_eq(unpacked.players[0].points, 50); + assert(unpacked.players[1].address.eq(game.players[1].address)); + assert_eq(unpacked.players[1].deck_strength, 200); + assert_eq(unpacked.players[1].points, 75); + + assert_eq(unpacked.rounds_cards[0].strength, 10); + assert_eq(unpacked.rounds_cards[0].points, 5); + assert_eq(unpacked.rounds_cards[3].strength, 40); + assert_eq(unpacked.rounds_cards[3].points, 35); + + assert(unpacked.started); + assert(!unpacked.finished); + assert(unpacked.claimed); + assert_eq(unpacked.current_player, 1); + assert_eq(unpacked.current_round, 1); + } + + #[test] + fn test_pack_unpack_game_all_false() { + let game = Game { + players: [ + PlayerEntry { address: AztecAddress::from_field(0), deck_strength: 0, points: 0 }, + PlayerEntry { address: AztecAddress::from_field(0), deck_strength: 0, points: 0 }, + ], + rounds_cards: [ + Card { strength: 0, points: 0 }, + Card { strength: 0, points: 0 }, + Card { strength: 0, points: 0 }, + Card { strength: 0, points: 0 }, + ], + started: false, + finished: false, + claimed: false, + current_player: 0, + current_round: 0, + }; + let unpacked = Game::unpack(game.pack()); + assert(!unpacked.started); + assert(!unpacked.finished); + assert(!unpacked.claimed); + assert_eq(unpacked.current_player, 0); + assert_eq(unpacked.current_round, 0); + } +} diff --git a/noir-projects/noir-contracts/contracts/app/lending_contract/src/asset.nr b/noir-projects/noir-contracts/contracts/app/lending_contract/src/asset.nr index 9920d353cc30..9c8a96da0387 100644 --- a/noir-projects/noir-contracts/contracts/app/lending_contract/src/asset.nr +++ b/noir-projects/noir-contracts/contracts/app/lending_contract/src/asset.nr @@ -1,4 +1,7 @@ -use aztec::protocol::{address::AztecAddress, traits::{Deserialize, Packable, Serialize}}; +use aztec::protocol::{ + address::AztecAddress, + traits::{Deserialize, FromField, Packable, Serialize, ToField}, +}; use std::meta::derive; /// Struct to be used to represent "totals". Generally, there should be one per Asset. @@ -10,11 +13,79 @@ use std::meta::derive; /// Note: Right now we are wasting so many writes. If changing last_updated_ts we will end /// up rewriting all the values. // docs:start:custom_struct_in_storage -#[derive(Deserialize, Packable, Serialize)] +#[derive(Deserialize, Serialize)] pub struct Asset { pub interest_accumulator: u128, pub last_updated_ts: u64, pub loan_to_value: u128, pub oracle: AztecAddress, } + +impl Packable for Asset { + let N: u32 = 3; + + fn pack(self) -> [Field; Self::N] { + [ + (self.interest_accumulator as Field) * 2.pow_32(64) + (self.last_updated_ts as Field), + self.loan_to_value as Field, + self.oracle.to_field(), + ] + } + + fn unpack(packed: [Field; Self::N]) -> Self { + let last_updated_ts = packed[0] as u64; + let interest_accumulator = ((packed[0] - last_updated_ts as Field) / 2.pow_32(64)) as u128; + let loan_to_value = packed[1] as u128; + let oracle = AztecAddress::from_field(packed[2]); + Self { interest_accumulator, last_updated_ts, loan_to_value, oracle } + } +} // docs:end:custom_struct_in_storage + +mod test { + use super::{Asset, AztecAddress, FromField, Packable}; + + #[test] + fn test_pack_unpack_asset() { + let asset = Asset { + interest_accumulator: 1000000, + last_updated_ts: 1700000000, + loan_to_value: 750000, + oracle: AztecAddress::from_field(0xabcdef), + }; + let unpacked = Asset::unpack(asset.pack()); + assert_eq(unpacked.interest_accumulator, asset.interest_accumulator); + assert_eq(unpacked.last_updated_ts, asset.last_updated_ts); + assert_eq(unpacked.loan_to_value, asset.loan_to_value); + assert(unpacked.oracle.eq(asset.oracle)); + } + + #[test] + fn test_pack_unpack_asset_zeros() { + let asset = Asset { + interest_accumulator: 0, + last_updated_ts: 0, + loan_to_value: 0, + oracle: AztecAddress::from_field(0), + }; + let unpacked = Asset::unpack(asset.pack()); + assert_eq(unpacked.interest_accumulator, 0); + assert_eq(unpacked.last_updated_ts, 0); + assert_eq(unpacked.loan_to_value, 0); + } + + #[test] + fn test_pack_unpack_asset_max() { + let asset = Asset { + interest_accumulator: 0xffffffffffffffffffffffffffffffff, + last_updated_ts: 0xffffffffffffffff, + loan_to_value: 0xffffffffffffffffffffffffffffffff, + oracle: AztecAddress::from_field(0xabcdef), + }; + let unpacked = Asset::unpack(asset.pack()); + assert_eq(unpacked.interest_accumulator, asset.interest_accumulator); + assert_eq(unpacked.last_updated_ts, asset.last_updated_ts); + assert_eq(unpacked.loan_to_value, asset.loan_to_value); + assert(unpacked.oracle.eq(asset.oracle)); + } +} diff --git a/noir-projects/noir-contracts/contracts/app/orderbook_contract/src/order.nr b/noir-projects/noir-contracts/contracts/app/orderbook_contract/src/order.nr index 6729af5201b0..c9faf3c6d0e1 100644 --- a/noir-projects/noir-contracts/contracts/app/orderbook_contract/src/order.nr +++ b/noir-projects/noir-contracts/contracts/app/orderbook_contract/src/order.nr @@ -3,7 +3,7 @@ use aztec::protocol::{address::AztecAddress, traits::{Deserialize, Packable, Ser // TODO: We do not necessarily need full 128 bits for the amounts so we could try to pack the whole order into 1 Field // and save on public storage costs. -#[derive(Deserialize, Eq, Packable, Serialize)] +#[derive(Deserialize, Eq, Serialize)] pub struct Order { // Amount of bid tokens pub bid_amount: u128, @@ -13,6 +13,24 @@ pub struct Order { pub bid_token_is_zero: bool, } +impl Packable for Order { + let N: u32 = 2; + + fn pack(self) -> [Field; Self::N] { + [ + (self.bid_amount as Field) * 2.pow_32(1) + (self.bid_token_is_zero as Field), + self.ask_amount as Field, + ] + } + + fn unpack(packed: [Field; Self::N]) -> Self { + let bid_token_is_zero = (packed[0] as u1) != 0; + let bid_amount = ((packed[0] - bid_token_is_zero as Field) / 2.pow_32(1)) as u128; + let ask_amount = packed[1] as u128; + Self { bid_amount, ask_amount, bid_token_is_zero } + } +} + impl Order { pub fn new( config: Config, @@ -33,7 +51,7 @@ impl Order { mod test { use crate::{config::Config, order::Order}; - use aztec::protocol::{address::AztecAddress, traits::FromField}; + use aztec::protocol::{address::AztecAddress, traits::{FromField, Packable}}; #[test] unconstrained fn new_order_valid_inputs() { @@ -84,4 +102,33 @@ mod test { let _ = Order::new(config, 100, 100, token2, token1); } + + #[test] + fn test_pack_unpack_order() { + let order = Order { bid_amount: 123456789, ask_amount: 987654321, bid_token_is_zero: true }; + let unpacked = Order::unpack(order.pack()); + assert_eq(unpacked.bid_amount, order.bid_amount); + assert_eq(unpacked.ask_amount, order.ask_amount); + assert(unpacked.bid_token_is_zero); + } + + #[test] + fn test_pack_unpack_order_false() { + let order = + Order { bid_amount: 123456789, ask_amount: 987654321, bid_token_is_zero: false }; + let unpacked = Order::unpack(order.pack()); + assert_eq(unpacked.bid_amount, order.bid_amount); + assert_eq(unpacked.ask_amount, order.ask_amount); + assert(!unpacked.bid_token_is_zero); + } + + #[test] + fn test_pack_unpack_order_max() { + let max_u128: u128 = 0xffffffffffffffffffffffffffffffff; + let order = Order { bid_amount: max_u128, ask_amount: max_u128, bid_token_is_zero: true }; + let unpacked = Order::unpack(order.pack()); + assert_eq(unpacked.bid_amount, max_u128); + assert_eq(unpacked.ask_amount, max_u128); + assert(unpacked.bid_token_is_zero); + } } diff --git a/yarn-project/aztec-node/src/aztec-node/server.ts b/yarn-project/aztec-node/src/aztec-node/server.ts index 172286005cbb..92dfc102a387 100644 --- a/yarn-project/aztec-node/src/aztec-node/server.ts +++ b/yarn-project/aztec-node/src/aztec-node/server.ts @@ -1046,7 +1046,11 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable { referenceBlock: BlockParameter, blockHash: BlockHash, ): Promise | undefined> { - const committedDb = await this.getWorldState(referenceBlock); + // The Noir circuit checks the archive membership proof against `anchor_block_header.last_archive.root`, + // which is the archive tree root BEFORE the anchor block was added (i.e. the state after block N-1). + // So we need the world state at block N-1, not block N, to produce a sibling path matching that root. + const referenceBlockNumber = await this.resolveBlockNumber(referenceBlock); + const committedDb = await this.getWorldState(BlockNumber(referenceBlockNumber - 1)); const [pathAndIndex] = await committedDb.findSiblingPaths(MerkleTreeId.ARCHIVE, [blockHash]); return pathAndIndex === undefined ? undefined @@ -1655,6 +1659,25 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable { return snapshot; } + /** Resolves a block parameter to a block number. */ + protected async resolveBlockNumber(block: BlockParameter): Promise { + if (block === 'latest') { + return BlockNumber(await this.blockSource.getBlockNumber()); + } + if (BlockHash.isBlockHash(block)) { + const initialBlockHash = await this.#getInitialHeaderHash(); + if (block.equals(initialBlockHash)) { + return BlockNumber.ZERO; + } + const header = await this.blockSource.getBlockHeaderByHash(block); + if (!header) { + throw new Error(`Block hash ${block.toString()} not found.`); + } + return header.getBlockNumber(); + } + return block as BlockNumber; + } + /** * Ensure we fully sync the world state * @returns A promise that fulfils once the world state is synced diff --git a/yarn-project/aztec.js/src/account/account.ts b/yarn-project/aztec.js/src/account/account.ts index 622a383e7ab8..1b7c72feecd8 100644 --- a/yarn-project/aztec.js/src/account/account.ts +++ b/yarn-project/aztec.js/src/account/account.ts @@ -53,8 +53,8 @@ export class BaseAccount implements Account { return this.entrypoint.createTxExecutionRequest(exec, gasSettings, chainInfo, options); } - wrapExecutionPayload(exec: ExecutionPayload, options?: any): Promise { - return this.entrypoint.wrapExecutionPayload(exec, options); + wrapExecutionPayload(exec: ExecutionPayload, chainInfo: ChainInfo, options?: any): Promise { + return this.entrypoint.wrapExecutionPayload(exec, chainInfo, options); } async createAuthWit(messageHashOrIntent: CallIntent | IntentInnerHash, chainInfo: ChainInfo): Promise { diff --git a/yarn-project/aztec.js/src/account/account_with_secret_key.ts b/yarn-project/aztec.js/src/account/account_with_secret_key.ts index 3c54e372333a..7bc50132bdec 100644 --- a/yarn-project/aztec.js/src/account/account_with_secret_key.ts +++ b/yarn-project/aztec.js/src/account/account_with_secret_key.ts @@ -32,8 +32,8 @@ export class AccountWithSecretKey implements Account { return this.account.createTxExecutionRequest(exec, gasSettings, chainInfo, options); } - wrapExecutionPayload(exec: ExecutionPayload, options?: any): Promise { - return this.account.wrapExecutionPayload(exec, options); + wrapExecutionPayload(exec: ExecutionPayload, chainInfo: ChainInfo, options?: any): Promise { + return this.account.wrapExecutionPayload(exec, chainInfo, options); } createAuthWit(intent: IntentInnerHash | CallIntent, chainInfo: ChainInfo): Promise { return this.account.createAuthWit(intent, chainInfo); diff --git a/yarn-project/aztec.js/src/account/signerless_account.ts b/yarn-project/aztec.js/src/account/signerless_account.ts index c6c2e7b7e861..6b1962dc6690 100644 --- a/yarn-project/aztec.js/src/account/signerless_account.ts +++ b/yarn-project/aztec.js/src/account/signerless_account.ts @@ -28,8 +28,8 @@ export class SignerlessAccount implements Account { return this.entrypoint.createTxExecutionRequest(exec, gasSettings, chainInfo); } - wrapExecutionPayload(exec: ExecutionPayload, options?: any): Promise { - return this.entrypoint.wrapExecutionPayload(exec, options); + wrapExecutionPayload(exec: ExecutionPayload, chainInfo: ChainInfo, options?: any): Promise { + return this.entrypoint.wrapExecutionPayload(exec, chainInfo, options); } createAuthWit(_intent: Fr | Buffer | IntentInnerHash | CallIntent): Promise { diff --git a/yarn-project/aztec.js/src/wallet/account_entrypoint_meta_payment_method.ts b/yarn-project/aztec.js/src/wallet/account_entrypoint_meta_payment_method.ts index 8e73d560e1c9..1bb1ebba4ea2 100644 --- a/yarn-project/aztec.js/src/wallet/account_entrypoint_meta_payment_method.ts +++ b/yarn-project/aztec.js/src/wallet/account_entrypoint_meta_payment_method.ts @@ -1,4 +1,5 @@ import { AccountFeePaymentMethodOptions } from '@aztec/entrypoints/account'; +import type { ChainInfo } from '@aztec/entrypoints/interfaces'; import { ProtocolContractAddress } from '@aztec/protocol-contracts'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; import type { GasSettings } from '@aztec/stdlib/gas'; @@ -25,6 +26,7 @@ import type { FeePaymentMethod } from '../fee/fee_payment_method.js'; export class AccountEntrypointMetaPaymentMethod implements FeePaymentMethod { constructor( private account: Account, + private chainInfo: ChainInfo, private paymentMethod?: FeePaymentMethod, private feeEntrypointOptions?: any, ) {} @@ -61,7 +63,7 @@ export class AccountEntrypointMetaPaymentMethod implements FeePaymentMethod { } // Use the generic wrapping mechanism from the account interface - return this.account.wrapExecutionPayload(innerPayload, options); + return this.account.wrapExecutionPayload(innerPayload, this.chainInfo, options); } getFeePayer(): Promise { diff --git a/yarn-project/aztec.js/src/wallet/deploy_account_method.ts b/yarn-project/aztec.js/src/wallet/deploy_account_method.ts index a6f6954db431..06fa52e36884 100644 --- a/yarn-project/aztec.js/src/wallet/deploy_account_method.ts +++ b/yarn-project/aztec.js/src/wallet/deploy_account_method.ts @@ -100,11 +100,12 @@ export class DeployAccountMethod exte * @param feeEntrypointOptions - Optional entrypoint-specific options for wrapping. If not provided, will be auto-computed based on the payment method. * @returns A FeePaymentMethod that routes the original one through the account's entrypoint (AccountEntrypointMetaPaymentMethod) */ - private getSelfFeePaymentMethod(originalPaymentMethod?: FeePaymentMethod, feeEntrypointOptions?: any) { + private async getSelfFeePaymentMethod(originalPaymentMethod?: FeePaymentMethod, feeEntrypointOptions?: any) { if (!this.address) { throw new Error('Instance is not yet constructed. This is a bug!'); } - return new AccountEntrypointMetaPaymentMethod(this.account, originalPaymentMethod, feeEntrypointOptions); + const chainInfo = await this.wallet.getChainInfo(); + return new AccountEntrypointMetaPaymentMethod(this.account, chainInfo, originalPaymentMethod, feeEntrypointOptions); } /** @@ -128,7 +129,10 @@ export class DeployAccountMethod exte const executionPayloads = [deploymentExecutionPayload]; // If this is a self-deployment, manage the fee accordingly if (opts?.deployer?.equals(AztecAddress.ZERO)) { - const feePaymentMethod = this.getSelfFeePaymentMethod(opts?.fee?.paymentMethod, opts?.fee?.feeEntrypointOptions); + const feePaymentMethod = await this.getSelfFeePaymentMethod( + opts?.fee?.paymentMethod, + opts?.fee?.feeEntrypointOptions, + ); const feeExecutionPayload = await feePaymentMethod.getExecutionPayload(); // Notice they are reversed (fee payment usually goes first): // this is because we need to construct the contract BEFORE it can pay for its own fee diff --git a/yarn-project/entrypoints/src/account_entrypoint.ts b/yarn-project/entrypoints/src/account_entrypoint.ts index 114910fa342a..b6bc73535fce 100644 --- a/yarn-project/entrypoints/src/account_entrypoint.ts +++ b/yarn-project/entrypoints/src/account_entrypoint.ts @@ -1,5 +1,6 @@ import { Fr } from '@aztec/foundation/curves/bn254'; import { type FunctionAbi, FunctionCall, FunctionSelector, encodeArguments } from '@aztec/stdlib/abi'; +import { computeOuterAuthWitHash } from '@aztec/stdlib/auth-witness'; import type { AztecAddress } from '@aztec/stdlib/aztec-address'; import type { GasSettings } from '@aztec/stdlib/gas'; import { ExecutionPayload, HashedValues, TxContext, TxExecutionRequest } from '@aztec/stdlib/tx'; @@ -66,7 +67,7 @@ export class DefaultAccountEntrypoint implements EntrypointInterface { options: DefaultAccountEntrypointOptions, ): Promise { const { authWitnesses, capsules, extraHashedArgs } = exec; - const callData = await this.#buildEntrypointCallData(exec, options); + const callData = await this.#buildEntrypointCallData(exec, chainInfo, options); const entrypointHashedArgs = await HashedValues.fromArgs(callData.encodedArgs); const txRequest = TxExecutionRequest.from({ firstCallArgsHash: entrypointHashedArgs.hash, @@ -84,10 +85,11 @@ export class DefaultAccountEntrypoint implements EntrypointInterface { async wrapExecutionPayload( exec: ExecutionPayload, + chainInfo: ChainInfo, options: DefaultAccountEntrypointOptions, ): Promise { const { authWitnesses, capsules, extraHashedArgs, feePayer } = exec; - const callData = await this.#buildEntrypointCallData(exec, options); + const callData = await this.#buildEntrypointCallData(exec, chainInfo, options); // Build the entrypoint function call const entrypointCall = FunctionCall.from({ @@ -114,10 +116,15 @@ export class DefaultAccountEntrypoint implements EntrypointInterface { * Builds the shared data needed for both creating a tx execution request and wrapping an execution payload. * This includes encoding calls, building entrypoint arguments, and creating the authwitness. * @param exec - The execution payload containing calls to encode + * @param chainInfo - Chain information (chainId and version) for replay protection * @param options - Account entrypoint options including tx nonce and fee payment method * @returns Encoded call data, ABI, function selector, and auth witness */ - async #buildEntrypointCallData(exec: ExecutionPayload, options: DefaultAccountEntrypointOptions) { + async #buildEntrypointCallData( + exec: ExecutionPayload, + chainInfo: ChainInfo, + options: DefaultAccountEntrypointOptions, + ) { const { calls } = exec; const { cancellable, txNonce, feePaymentMethodOptions } = options; @@ -129,7 +136,9 @@ export class DefaultAccountEntrypoint implements EntrypointInterface { const functionSelector = await FunctionSelector.fromNameAndParameters(abi.name, abi.parameters); - const payloadAuthWitness = await this.auth.createAuthWit(await encodedCalls.hash()); + const payloadHash = await encodedCalls.hash(); + const messageHash = await computeOuterAuthWitHash(this.address, chainInfo.chainId, chainInfo.version, payloadHash); + const payloadAuthWitness = await this.auth.createAuthWit(messageHash); return { encodedCalls, diff --git a/yarn-project/entrypoints/src/default_entrypoint.ts b/yarn-project/entrypoints/src/default_entrypoint.ts index 0b5274908bee..f7bed850c1ea 100644 --- a/yarn-project/entrypoints/src/default_entrypoint.ts +++ b/yarn-project/entrypoints/src/default_entrypoint.ts @@ -41,7 +41,7 @@ export class DefaultEntrypoint implements EntrypointInterface { ); } - async wrapExecutionPayload(exec: ExecutionPayload, _options?: any): Promise { + async wrapExecutionPayload(exec: ExecutionPayload, _chainInfo: ChainInfo, _options?: any): Promise { if (exec.calls.length !== 1) { throw new Error(`DefaultEntrypoint can only wrap a single call, got ${exec.calls.length}`); } diff --git a/yarn-project/entrypoints/src/default_multi_call_entrypoint.ts b/yarn-project/entrypoints/src/default_multi_call_entrypoint.ts index 60c905a415b0..9eef30e7565b 100644 --- a/yarn-project/entrypoints/src/default_multi_call_entrypoint.ts +++ b/yarn-project/entrypoints/src/default_multi_call_entrypoint.ts @@ -37,7 +37,7 @@ export class DefaultMultiCallEntrypoint implements EntrypointInterface { return Promise.resolve(txRequest); } - async wrapExecutionPayload(exec: ExecutionPayload, _options?: any): Promise { + async wrapExecutionPayload(exec: ExecutionPayload, _chainInfo: ChainInfo, _options?: any): Promise { const { authWitnesses, capsules, extraHashedArgs } = exec; const callData = await this.#buildEntrypointCallData(exec); const entrypointCall = FunctionCall.from({ diff --git a/yarn-project/entrypoints/src/interfaces.ts b/yarn-project/entrypoints/src/interfaces.ts index 182b5c0bbffb..ebdd30610a1d 100644 --- a/yarn-project/entrypoints/src/interfaces.ts +++ b/yarn-project/entrypoints/src/interfaces.ts @@ -51,11 +51,12 @@ export interface EntrypointInterface { * of a single entrypoint call. * * @param exec - The execution payload to wrap + * @param chainInfo - Chain information (chainId and version) for replay protection * @param options - Implementation-specific options * @returns A new execution payload with a single call to this entrypoint * @throws Error if the payload cannot be wrapped (e.g., exceeds call limit) */ - wrapExecutionPayload(exec: ExecutionPayload, options?: any): Promise; + wrapExecutionPayload(exec: ExecutionPayload, chainInfo: ChainInfo, options?: any): Promise; } /** Creates authorization witnesses. */