diff --git a/lee/state_machine/src/fees.rs b/lee/state_machine/src/fees.rs new file mode 100644 index 000000000..fd99e91b1 --- /dev/null +++ b/lee/state_machine/src/fees.rs @@ -0,0 +1,165 @@ +//! Fee fields and payer authorization shared by signed message kinds. + +use borsh::{BorshDeserialize, BorshSerialize}; + +use crate::{AccountId, public_transaction::WitnessSet}; + +/// The payer recorded on system-generated transactions, which are fee-exempt. +pub const SYSTEM_PAYER: AccountId = AccountId::new([0_u8; 32]); + +/// The signed fee fields of a transaction message. Stored flat on each message +/// kind and covered by its hash, so every witness signature authorizes them. +#[derive(Debug, Clone, Copy, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +pub struct FeeFields { + /// The account debited for this transaction's fee. Never inferred from the + /// witness set: it must be designated here and authorized by a signature. + pub payer: AccountId, + pub gas_limit: u64, + pub tip: u64, + pub max_fee: u128, +} + +impl FeeFields { + pub const ZERO: Self = Self::new(SYSTEM_PAYER, 0, 0, 0); + + #[must_use] + pub const fn new(payer: AccountId, gas_limit: u64, tip: u64, max_fee: u128) -> Self { + Self { + payer, + gas_limit, + tip, + max_fee, + } + } +} + +/// A message kind that carries fee fields under a domain-separated hash. +pub trait SignedMessage { + fn signing_hash(&self) -> [u8; 32]; + fn payer(&self) -> AccountId; +} + +/// Accounts whose valid signature over `message` accompanies the transaction: +/// the ordinary witnesses plus the fee witness, if any. +#[must_use] +pub fn fee_authorized_account_ids( + message: &M, + witness_set: &WitnessSet, +) -> Vec { + let message_hash = message.signing_hash(); + witness_set + .signatures_and_public_keys() + .iter() + .chain(witness_set.fee_witness()) + .filter(|(signature, public_key)| signature.is_valid_for(&message_hash, public_key)) + .map(|(_, public_key)| AccountId::from(public_key)) + .collect() +} + +/// Whether the designated payer's authorization accompanies the transaction. +#[must_use] +pub fn is_fee_authorized(message: &M, witness_set: &WitnessSet) -> bool { + fee_authorized_account_ids(message, witness_set).contains(&message.payer()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + PrivateKey, PublicKey, + public_transaction::{Message, WitnessSet}, + }; + + fn keys() -> (PrivateKey, PrivateKey) { + ( + PrivateKey::try_new([1_u8; 32]).expect("valid key"), + PrivateKey::try_new([2_u8; 32]).expect("valid key"), + ) + } + + fn account_id_of(key: &PrivateKey) -> AccountId { + AccountId::from(&PublicKey::new_from_private_key(key)) + } + + fn message_with_fees(fees: FeeFields) -> Message { + let (signer_key, _) = keys(); + Message::try_new_with_fees( + [0_u32; 8], + vec![account_id_of(&signer_key)], + vec![0_u128.into()], + vec![1_u8, 2, 3], + fees, + ) + .expect("valid message") + } + + #[test] + fn self_payer_is_authorized_by_ordinary_signature() { + let (signer_key, _) = keys(); + let message = + message_with_fees(FeeFields::new(account_id_of(&signer_key), 1_000, 0, 10_000)); + let witness_set = WitnessSet::for_message(&message, &[&signer_key]); + assert!(is_fee_authorized(&message, &witness_set)); + } + + #[test] + fn sponsor_payer_requires_the_fee_witness() { + let (signer_key, sponsor_key) = keys(); + let message = message_with_fees(FeeFields::new( + account_id_of(&sponsor_key), + 1_000, + 0, + 10_000, + )); + + let without = WitnessSet::for_message(&message, &[&signer_key]); + assert!(!is_fee_authorized(&message, &without)); + + let with = WitnessSet::for_message(&message, &[&signer_key]) + .with_fee_signer(&message, &sponsor_key); + assert!(is_fee_authorized(&message, &with)); + assert!(with.is_valid_for(&message)); + } + + #[test] + fn tampered_fee_fields_invalidate_every_signature() { + // The fee fields are inside the signed hash: raising gas_limit after + // signing must break both the ordinary and the fee-witness signatures. + let (signer_key, sponsor_key) = keys(); + let message = message_with_fees(FeeFields::new( + account_id_of(&sponsor_key), + 1_000, + 0, + 10_000, + )); + let witness_set = WitnessSet::for_message(&message, &[&signer_key]) + .with_fee_signer(&message, &sponsor_key); + + let mut tampered = message; + tampered.gas_limit = 999_999; + assert!(!witness_set.is_valid_for(&tampered)); + assert!(!is_fee_authorized(&tampered, &witness_set)); + } + + #[test] + fn invalid_sponsor_signature_invalidates_the_witness_set() { + let (signer_key, sponsor_key) = keys(); + let message = message_with_fees(FeeFields::ZERO); + let other = + Message::try_new_with_fees([1_u32; 8], vec![], vec![], vec![9_u8], FeeFields::ZERO) + .expect("valid message"); + // Fee witness signs a DIFFERENT message: the set must be invalid. + let witness_set = + WitnessSet::for_message(&message, &[&signer_key]).with_fee_signer(&other, &sponsor_key); + assert!(!witness_set.is_valid_for(&message)); + } + + #[test] + fn zero_fields_round_trip_and_default_constructor_matches() { + let message = message_with_fees(FeeFields::ZERO); + assert_eq!(message.fees(), FeeFields::ZERO); + let bytes = borsh::to_vec(&message).expect("serializes"); + let back: Message = borsh::from_slice(&bytes).expect("deserializes"); + assert_eq!(back, message); + } +} diff --git a/lee/state_machine/src/lib.rs b/lee/state_machine/src/lib.rs index d5f7d18cd..d5fc3b5b7 100644 --- a/lee/state_machine/src/lib.rs +++ b/lee/state_machine/src/lib.rs @@ -3,6 +3,9 @@ reason = "We prefer to group methods by functionality rather than by type for encoding" )] +pub use fees::{ + FeeFields, SYSTEM_PAYER, SignedMessage, fee_authorized_account_ids, is_fee_authorized, +}; pub use lee_core::{ GENESIS_BLOCK_ID, SharedSecretKey, account::{Account, AccountId, Balance, Data, Fee, Gas}, @@ -23,6 +26,7 @@ pub use validated_state_diff::ValidatedStateDiff; pub mod encoding; pub mod error; +pub mod fees; mod merkle_tree; pub mod privacy_preserving_transaction; pub mod program; diff --git a/lee/state_machine/src/public_transaction/message.rs b/lee/state_machine/src/public_transaction/message.rs index feafc5391..27a79eb3e 100644 --- a/lee/state_machine/src/public_transaction/message.rs +++ b/lee/state_machine/src/public_transaction/message.rs @@ -6,7 +6,7 @@ use lee_core::{ use serde::Serialize; use sha2::{Digest as _, Sha256}; -use crate::{AccountId, error::LeeError, program::Program}; +use crate::{AccountId, error::LeeError, fees::FeeFields, program::Program}; const PREFIX: &[u8; 32] = b"/LEE/v0.3/Message/Public/\x00\x00\x00\x00\x00\x00\x00"; @@ -16,6 +16,10 @@ pub struct Message { pub account_ids: Vec, pub nonces: Vec, pub instruction_data: InstructionData, + pub payer: AccountId, + pub gas_limit: u64, + pub tip: u64, + pub max_fee: u128, } impl std::fmt::Debug for Message { @@ -31,25 +35,49 @@ impl std::fmt::Debug for Message { .field("account_ids", &self.account_ids) .field("nonces", &self.nonces) .field("instruction_data", &self.instruction_data) + .field("payer", &self.payer) + .field("gas_limit", &self.gas_limit) + .field("tip", &self.tip) + .field("max_fee", &self.max_fee) .finish() } } impl Message { + /// Builds a message with zero fee fields. Correct for exempt system + /// transactions and while the zero-fee policy holds; charged transactions + /// use [`Self::try_new_with_fees`]. pub fn try_new( program_id: ProgramId, account_ids: Vec, nonces: Vec, instruction: T, + ) -> Result { + Self::try_new_with_fees( + program_id, + account_ids, + nonces, + instruction, + FeeFields::ZERO, + ) + } + + pub fn try_new_with_fees( + program_id: ProgramId, + account_ids: Vec, + nonces: Vec, + instruction: T, + fees: FeeFields, ) -> Result { let instruction_data = Program::serialize_instruction(instruction)?; - Ok(Self { + Ok(Self::new_preserialized( program_id, account_ids, nonces, instruction_data, - }) + fees, + )) } #[must_use] @@ -58,15 +86,25 @@ impl Message { account_ids: Vec, nonces: Vec, instruction_data: InstructionData, + fees: FeeFields, ) -> Self { Self { program_id, account_ids, nonces, instruction_data, + payer: fees.payer, + gas_limit: fees.gas_limit, + tip: fees.tip, + max_fee: fees.max_fee, } } + #[must_use] + pub const fn fees(&self) -> FeeFields { + FeeFields::new(self.payer, self.gas_limit, self.tip, self.max_fee) + } + #[must_use] pub fn hash(&self) -> [u8; 32] { let mut bytes = Vec::with_capacity( @@ -82,6 +120,16 @@ impl Message { } } +impl crate::fees::SignedMessage for Message { + fn signing_hash(&self) -> [u8; 32] { + self.hash() + } + + fn payer(&self) -> AccountId { + self.payer + } +} + #[cfg(test)] mod tests { use lee_core::account::{AccountId, Nonce}; @@ -96,6 +144,7 @@ mod tests { vec![AccountId::new([42_u8; 32])], vec![Nonce(5)], vec![], + crate::fees::FeeFields::ZERO, ); // program_id: [1_u32; 8], each word as LE u32 @@ -108,6 +157,9 @@ mod tests { // nonces: u32 len=1, then Nonce(5) as LE u128 let nonces_bytes: &[u8] = &[1, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; let instruction_data_bytes: &[u8] = &[0_u8; 4]; + // Fee fields (FeeFields::ZERO): payer (32 zero bytes), then gas_limit + // (u64 LE), tip (u64 LE), max_fee (u128 LE), all zero. + let fee_fields_bytes: &[u8] = &[0_u8; 32 + 8 + 8 + 16]; let expected_borsh_vec: Vec = [ program_id_bytes, @@ -115,6 +167,7 @@ mod tests { account_ids_bytes, nonces_bytes, instruction_data_bytes, + fee_fields_bytes, ] .concat(); let expected_borsh: &[u8] = &expected_borsh_vec; diff --git a/lee/state_machine/src/public_transaction/transaction.rs b/lee/state_machine/src/public_transaction/transaction.rs index e0d1fe1be..89e93346a 100644 --- a/lee/state_machine/src/public_transaction/transaction.rs +++ b/lee/state_machine/src/public_transaction/transaction.rs @@ -251,6 +251,7 @@ pub mod tests { vec![], vec![], vec![0; 4], + crate::fees::FeeFields::ZERO, ); let witness_set = WitnessSet::from_raw_parts(vec![]); let tx = PublicTransaction::new(message, witness_set); diff --git a/lee/state_machine/src/public_transaction/witness_set.rs b/lee/state_machine/src/public_transaction/witness_set.rs index 1605f488a..744020282 100644 --- a/lee/state_machine/src/public_transaction/witness_set.rs +++ b/lee/state_machine/src/public_transaction/witness_set.rs @@ -5,6 +5,7 @@ use crate::{PrivateKey, PublicKey, Signature, public_transaction::Message}; #[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] pub struct WitnessSet { pub(crate) signatures_and_public_keys: Vec<(Signature, PublicKey)>, + pub(crate) fee_witness: Option<(Signature, PublicKey)>, } impl WitnessSet { @@ -22,13 +23,35 @@ impl WitnessSet { .collect(); Self { signatures_and_public_keys, + fee_witness: None, } } + /// Adds a sponsor's fee authorization: a signature over the same message + /// hash by an account outside the ordinary witness set. + #[must_use] + pub fn with_fee_signer(mut self, message: &Message, payer_key: &PrivateKey) -> Self { + let message_hash = message.hash(); + self.fee_witness = Some(( + Signature::new(payer_key, &message_hash), + PublicKey::new_from_private_key(payer_key), + )); + self + } + + #[must_use] + pub const fn fee_witness(&self) -> Option<&(Signature, PublicKey)> { + self.fee_witness.as_ref() + } + #[must_use] pub fn is_valid_for(&self, message: &Message) -> bool { let message_hash = message.hash(); - for (signature, public_key) in self.signatures_and_public_keys() { + for (signature, public_key) in self + .signatures_and_public_keys() + .iter() + .chain(self.fee_witness()) + { if !signature.is_valid_for(&message_hash, public_key) { return false; } @@ -50,6 +73,7 @@ impl WitnessSet { pub const fn from_raw_parts(signatures_and_public_keys: Vec<(Signature, PublicKey)>) -> Self { Self { signatures_and_public_keys, + fee_witness: None, } } } diff --git a/lez/explorer_service/src/components/transaction_details.rs b/lez/explorer_service/src/components/transaction_details.rs index 01e5bc485..70b1380b7 100644 --- a/lez/explorer_service/src/components/transaction_details.rs +++ b/lez/explorer_service/src/components/transaction_details.rs @@ -19,6 +19,10 @@ pub fn PublicTxDetails(tx: PublicTransaction) -> impl IntoView { account_ids, nonces, instruction_data, + payer, + gas_limit, + tip, + max_fee, } = message; let WitnessSet { signatures_and_public_keys, @@ -51,6 +55,16 @@ pub fn PublicTxDetails(tx: PublicTransaction) -> impl IntoView { "Signatures:" {signatures_count.to_string()} +
+ "Fee Payer:" + {payer.to_string()} +
+
+ "Gas Limit / Tip / Max Fee:" + + {format!("{gas_limit} / {tip} / {max_fee}")} + +

"Accounts"

diff --git a/lez/indexer/ffi/src/api/types/transaction.rs b/lez/indexer/ffi/src/api/types/transaction.rs index 83f50ede5..c66615fa9 100644 --- a/lez/indexer/ffi/src/api/types/transaction.rs +++ b/lez/indexer/ffi/src/api/types/transaction.rs @@ -65,6 +65,12 @@ impl From> for PublicTransaction { std_vec.into_iter().map(Into::into).collect() }, instruction_data: value.message.instruction_data.into(), + // FIXME: The FFI surface does not carry fee fields yet; + // reconstruct them as zero (the exempt/system value). + payer: AccountId { value: [0; 32] }, + gas_limit: 0, + tip: 0, + max_fee: 0, }, witness_set: WitnessSet { signatures_and_public_keys: { @@ -100,6 +106,11 @@ impl From for FfiPublicMessage { account_ids, nonces, instruction_data, + // FIXME: Not carried over the FFI yet. + payer: _, + gas_limit: _, + tip: _, + max_fee: _, } = value; Self { diff --git a/lez/indexer/service/protocol/src/convert.rs b/lez/indexer/service/protocol/src/convert.rs index c4a5935c0..58a629f44 100644 --- a/lez/indexer/service/protocol/src/convert.rs +++ b/lez/indexer/service/protocol/src/convert.rs @@ -249,12 +249,20 @@ impl From for PublicMessage { account_ids, nonces, instruction_data, + payer, + gas_limit, + tip, + max_fee, } = value; Self { program_id: program_id.into(), account_ids: account_ids.into_iter().map(Into::into).collect(), nonces: nonces.iter().map(|x| x.0).collect(), instruction_data, + payer: payer.into(), + gas_limit, + tip, + max_fee, } } } @@ -266,6 +274,10 @@ impl From for lee::public_transaction::Message { account_ids, nonces, instruction_data, + payer, + gas_limit, + tip, + max_fee, } = value; Self::new_preserialized( program_id.into(), @@ -275,6 +287,7 @@ impl From for lee::public_transaction::Message { .map(|x| lee_core::account::Nonce(*x)) .collect(), instruction_data, + lee::FeeFields::new(payer.into(), gas_limit, tip, max_fee), ) } } diff --git a/lez/indexer/service/protocol/src/lib.rs b/lez/indexer/service/protocol/src/lib.rs index 895d4c296..147edd13a 100644 --- a/lez/indexer/service/protocol/src/lib.rs +++ b/lez/indexer/service/protocol/src/lib.rs @@ -222,6 +222,10 @@ pub struct PublicMessage { pub account_ids: Vec, pub nonces: Vec, pub instruction_data: InstructionData, + pub payer: AccountId, + pub gas_limit: u64, + pub tip: u64, + pub max_fee: u128, } pub type InstructionData = Vec; diff --git a/lez/indexer/service/src/mock_service.rs b/lez/indexer/service/src/mock_service.rs index c9a1912a1..ac385e91a 100644 --- a/lez/indexer/service/src/mock_service.rs +++ b/lez/indexer/service/src/mock_service.rs @@ -366,6 +366,10 @@ fn mock_public_tx( ], nonces: vec![block_id as u128, (block_id + 1) as u128], instruction_data: vec![1, 2, 3, 4], + payer: AccountId { value: [0; 32] }, + gas_limit: 0, + tip: 0, + max_fee: 0, }, witness_set: WitnessSet { signatures_and_public_keys: vec![], diff --git a/lez/sequencer/core/src/gossip/validation.rs b/lez/sequencer/core/src/gossip/validation.rs index 77bbe4397..5b0edbe73 100644 --- a/lez/sequencer/core/src/gossip/validation.rs +++ b/lez/sequencer/core/src/gossip/validation.rs @@ -9,6 +9,10 @@ use common::transaction::LeeTransaction; const BLOCK_HEADER_OVERHEAD: u64 = 200; #[derive(Debug)] +#[expect( + clippy::large_enum_variant, + reason = "Accept dominates and the value is transient per gossiped message; boxing would add an allocation on the hot validation path" +)] pub enum TxEvaluation { /// Structurally valid and authenticated; forward and admit. Accept(LeeTransaction), diff --git a/lez/wallet/src/lib.rs b/lez/wallet/src/lib.rs index 96c25a1d8..24170891e 100644 --- a/lez/wallet/src/lib.rs +++ b/lez/wallet/src/lib.rs @@ -858,6 +858,7 @@ impl WalletCore { account_ids, nonces, instruction_data, + lee::FeeFields::ZERO, ); let message_hash = message.hash(); diff --git a/test_fixtures/fixtures/prebuilt_sequencer_db.dump b/test_fixtures/fixtures/prebuilt_sequencer_db.dump index ff0a133a0..39e22e2b3 100644 Binary files a/test_fixtures/fixtures/prebuilt_sequencer_db.dump and b/test_fixtures/fixtures/prebuilt_sequencer_db.dump differ