From 150e53476a3c80c39ef64626e43c94147d1f29d7 Mon Sep 17 00:00:00 2001 From: benesjan Date: Mon, 16 Mar 2026 08:35:05 +0000 Subject: [PATCH] feat: implement manual Packable for structs with sub-Field members Replace `#[derive(Packable)]` with efficient manual implementations for 6 structs that had sub-optimal packing (multiple sub-Field members each occupying a full Field). This avoids the new compile-time warning and reduces storage/hashing costs. - Asset: 4 -> 3 Fields (pack u128 + u64 together) - Order: 3 -> 2 Fields (pack u128 + bool together) - Game: 13 -> 9 Fields (pack 3 bools + 2 u32s together) - PlayerEntry: 3 -> 2 Fields (pack u32 + u64 together) - Card: 2 -> 1 Field (pack 2 u32s together) - SubscriptionNote: 2 -> 1 Field (pack 2 u32s together) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/subscription_note.nr | 44 ++++- .../app/card_game_contract/src/cards.nr | 54 ++++- .../app/card_game_contract/src/game.nr | 185 +++++++++++++++++- .../app/lending_contract/src/asset.nr | 75 ++++++- .../app/orderbook_contract/src/order.nr | 51 ++++- 5 files changed, 395 insertions(+), 14 deletions(-) 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); + } }