diff --git a/examples/program_deployment/src/bin/run_hello_world.rs b/examples/program_deployment/src/bin/run_hello_world.rs index f58f2ec0e..871ef0987 100644 --- a/examples/program_deployment/src/bin/run_hello_world.rs +++ b/examples/program_deployment/src/bin/run_hello_world.rs @@ -53,7 +53,14 @@ async fn main() { // `run_hello_world_with_authorization` on how to use them. let nonces = vec![]; let signing_keys = []; - let message = Message::try_new(program.id(), vec![account_id], nonces, greeting).unwrap(); + let message = Message::try_new( + program.id(), + vec![account_id], + nonces, + greeting, + lee::FeeFields::ZERO, + ) + .unwrap(); let witness_set = WitnessSet::for_message(&message, &signing_keys); let tx = PublicTransaction::new(message, witness_set); diff --git a/examples/program_deployment/src/bin/run_hello_world_through_tail_call.rs b/examples/program_deployment/src/bin/run_hello_world_through_tail_call.rs index 00254bfdc..881d18f70 100644 --- a/examples/program_deployment/src/bin/run_hello_world_through_tail_call.rs +++ b/examples/program_deployment/src/bin/run_hello_world_through_tail_call.rs @@ -48,8 +48,14 @@ async fn main() { let instruction_data = (); let nonces = vec![]; let signing_keys = []; - let message = - Message::try_new(program.id(), vec![account_id], nonces, instruction_data).unwrap(); + let message = Message::try_new( + program.id(), + vec![account_id], + nonces, + instruction_data, + lee::FeeFields::ZERO, + ) + .unwrap(); let witness_set = WitnessSet::for_message(&message, &signing_keys); let tx = PublicTransaction::new(message, witness_set); diff --git a/examples/program_deployment/src/bin/run_hello_world_with_authorization.rs b/examples/program_deployment/src/bin/run_hello_world_with_authorization.rs index f7ad36b12..0a90ffb35 100644 --- a/examples/program_deployment/src/bin/run_hello_world_with_authorization.rs +++ b/examples/program_deployment/src/bin/run_hello_world_with_authorization.rs @@ -65,7 +65,14 @@ async fn main() { .await .expect("Node should be reachable to query account data"); let signing_keys = [&signing_key]; - let message = Message::try_new(program.id(), vec![account_id], nonces, greeting).unwrap(); + let message = Message::try_new( + program.id(), + vec![account_id], + nonces, + greeting, + lee::FeeFields::ZERO, + ) + .unwrap(); // Pass the signing key to sign the message. This will be used by the node // to flag the pre_state as `is_authorized` when executing the program let witness_set = WitnessSet::for_message(&message, &signing_keys); diff --git a/examples/program_deployment/src/bin/run_hello_world_with_authorization_through_tail_call_with_pda.rs b/examples/program_deployment/src/bin/run_hello_world_with_authorization_through_tail_call_with_pda.rs index a29bd2638..1ccadf593 100644 --- a/examples/program_deployment/src/bin/run_hello_world_with_authorization_through_tail_call_with_pda.rs +++ b/examples/program_deployment/src/bin/run_hello_world_with_authorization_through_tail_call_with_pda.rs @@ -51,7 +51,14 @@ async fn main() { let instruction_data = (); let nonces = vec![]; let signing_keys = []; - let message = Message::try_new(program.id(), account_ids, nonces, instruction_data).unwrap(); + let message = Message::try_new( + program.id(), + account_ids, + nonces, + instruction_data, + lee::FeeFields::ZERO, + ) + .unwrap(); let witness_set = WitnessSet::for_message(&message, &signing_keys); let tx = PublicTransaction::new(message, witness_set); diff --git a/examples/program_deployment/src/bin/run_hello_world_with_move_function.rs b/examples/program_deployment/src/bin/run_hello_world_with_move_function.rs index c03563ad4..2d4d3d532 100644 --- a/examples/program_deployment/src/bin/run_hello_world_with_move_function.rs +++ b/examples/program_deployment/src/bin/run_hello_world_with_move_function.rs @@ -81,6 +81,7 @@ async fn main() { vec![account_id], nonces, instruction, + lee::FeeFields::ZERO, ) .unwrap(); let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); @@ -120,6 +121,7 @@ async fn main() { vec![from, to], nonces, instruction, + lee::FeeFields::ZERO, ) .unwrap(); let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); diff --git a/integration_tests/tests/auth_transfer/private.rs b/integration_tests/tests/auth_transfer/private.rs index b60ee9561..4f108f63d 100644 --- a/integration_tests/tests/auth_transfer/private.rs +++ b/integration_tests/tests/auth_transfer/private.rs @@ -580,7 +580,11 @@ async fn ppt_cant_chain_call_faucet() -> Result<()> { let faucet_chain_caller = test_programs::faucet_chain_caller(); let deploy_tx = LeeTransaction::ProgramDeployment(lee::ProgramDeploymentTransaction::new( - lee::program_deployment_transaction::Message::new(faucet_chain_caller.elf().to_owned()), + lee::program_deployment_transaction::Message::new( + faucet_chain_caller.elf().to_owned(), + lee::FeeFields::ZERO, + ), + lee::public_transaction::WitnessSet::from_raw_parts(vec![]), )); ctx.sequencer_client().send_transaction(deploy_tx).await?; diff --git a/integration_tests/tests/auth_transfer/public.rs b/integration_tests/tests/auth_transfer/public.rs index ea0838efd..01e38ab54 100644 --- a/integration_tests/tests/auth_transfer/public.rs +++ b/integration_tests/tests/auth_transfer/public.rs @@ -316,6 +316,7 @@ async fn cannot_transfer_funds_from_system_faucet_account() -> Result<()> { vec![faucet_account_id, recipient], vec![], authenticated_transfer_core::Instruction::Transfer { amount }, + lee::FeeFields::ZERO, )?; let tx = lee::PublicTransaction::new( message, @@ -362,6 +363,7 @@ async fn cannot_execute_faucet_program() -> Result<()> { recipient_id: recipient, amount, }, + lee::FeeFields::ZERO, )?; let tx = lee::PublicTransaction::new( message, @@ -392,7 +394,11 @@ async fn user_tx_that_chain_calls_faucet_is_dropped() -> Result<()> { let faucet_chain_caller = test_programs::faucet_chain_caller(); let deploy_tx = LeeTransaction::ProgramDeployment(lee::ProgramDeploymentTransaction::new( - lee::program_deployment_transaction::Message::new(faucet_chain_caller.elf().to_owned()), + lee::program_deployment_transaction::Message::new( + faucet_chain_caller.elf().to_owned(), + lee::FeeFields::ZERO, + ), + lee::public_transaction::WitnessSet::from_raw_parts(vec![]), )); ctx.sequencer_client().send_transaction(deploy_tx).await?; @@ -411,6 +417,7 @@ async fn user_tx_that_chain_calls_faucet_is_dropped() -> Result<()> { vec![faucet_account_id, attacker_vault_id], vec![], (faucet_program_id, vault_program_id, attacker, amount), + lee::FeeFields::ZERO, )?; let attack_tx = LeeTransaction::Public(lee::PublicTransaction::new( message, diff --git a/integration_tests/tests/block_size_limit.rs b/integration_tests/tests/block_size_limit.rs index d97b695d8..50c627ed1 100644 --- a/integration_tests/tests/block_size_limit.rs +++ b/integration_tests/tests/block_size_limit.rs @@ -33,8 +33,12 @@ async fn reject_oversized_transaction() -> Result<()> { // Create a 1.1 MiB binary to ensure it exceeds the limit let oversized_binary = vec![0_u8; 1100 * 1024]; // 1.1 MiB binary - let message = lee::program_deployment_transaction::Message::new(oversized_binary); - let tx = lee::ProgramDeploymentTransaction::new(message); + let message = + lee::program_deployment_transaction::Message::new(oversized_binary, lee::FeeFields::ZERO); + let tx = lee::ProgramDeploymentTransaction::new( + message, + lee::public_transaction::WitnessSet::from_raw_parts(vec![]), + ); // Try to submit the transaction and expect an error let result = ctx @@ -74,8 +78,12 @@ async fn accept_transaction_within_limit() -> Result<()> { // Create a small program deployment that should fit let small_binary = vec![0_u8; 1024]; // 1 KiB binary - let message = lee::program_deployment_transaction::Message::new(small_binary); - let tx = lee::ProgramDeploymentTransaction::new(message); + let message = + lee::program_deployment_transaction::Message::new(small_binary, lee::FeeFields::ZERO); + let tx = lee::ProgramDeploymentTransaction::new( + message, + lee::public_transaction::WitnessSet::from_raw_parts(vec![]), + ); // This should succeed let result = ctx @@ -118,7 +126,11 @@ async fn transaction_deferred_to_next_block_when_current_full() -> Result<()> { ctx.sequencer_client() .send_transaction(LeeTransaction::ProgramDeployment( lee::ProgramDeploymentTransaction::new( - lee::program_deployment_transaction::Message::new(claimer.elf().to_owned()), + lee::program_deployment_transaction::Message::new( + claimer.elf().to_owned(), + lee::FeeFields::ZERO, + ), + lee::public_transaction::WitnessSet::from_raw_parts(vec![]), ), )) .await?; @@ -126,7 +138,11 @@ async fn transaction_deferred_to_next_block_when_current_full() -> Result<()> { ctx.sequencer_client() .send_transaction(LeeTransaction::ProgramDeployment( lee::ProgramDeploymentTransaction::new( - lee::program_deployment_transaction::Message::new(chain_caller.elf().to_owned()), + lee::program_deployment_transaction::Message::new( + chain_caller.elf().to_owned(), + lee::FeeFields::ZERO, + ), + lee::public_transaction::WitnessSet::from_raw_parts(vec![]), ), )) .await?; diff --git a/integration_tests/tests/bridge.rs b/integration_tests/tests/bridge.rs index 7e59d6c6f..818beda2a 100644 --- a/integration_tests/tests/bridge.rs +++ b/integration_tests/tests/bridge.rs @@ -39,6 +39,7 @@ async fn public_bridge_deposit_invocation_is_dropped() -> anyhow::Result<()> { recipient_id, amount: 1, }, + lee::FeeFields::ZERO, ) .context("Failed to build public bridge deposit transaction")?; @@ -88,6 +89,7 @@ async fn public_bridge_deposit_with_zero_amount_is_rejected() -> anyhow::Result< recipient_id, amount: 0, }, + lee::FeeFields::ZERO, ) .context("Failed to build zero-amount public bridge deposit transaction")?; @@ -456,6 +458,7 @@ async fn private_bridge_deposit_invocation_is_dropped() -> anyhow::Result<()> { // vault_core::Instruction::Claim { // amount: u128::from(amount), // }, +// lee::FeeFields::ZERO, // ) // .context("Failed to build vault claim message")?; diff --git a/integration_tests/tests/cross_zone_bridge.rs b/integration_tests/tests/cross_zone_bridge.rs index 0c334088c..b999ab1e8 100644 --- a/integration_tests/tests/cross_zone_bridge.rs +++ b/integration_tests/tests/cross_zone_bridge.rs @@ -166,8 +166,14 @@ fn build_lock_tx( outbox_pda(outbox_id, &target_zone, ordinal), ]; // One nonce per signature: the holder signs, at its genesis nonce 0. - let message = Message::try_new(bridge_lock_id, accounts, vec![0_u128.into()], lock) - .expect("build lock message"); + let message = Message::try_new( + bridge_lock_id, + accounts, + vec![0_u128.into()], + lock, + lee::FeeFields::ZERO, + ) + .expect("build lock message"); let witness = WitnessSet::for_message(&message, &[holder_key]); LeeTransaction::Public(PublicTransaction::new(message, witness)) } diff --git a/integration_tests/tests/cross_zone_ingress_guard.rs b/integration_tests/tests/cross_zone_ingress_guard.rs index 86731477f..7f9502784 100644 --- a/integration_tests/tests/cross_zone_ingress_guard.rs +++ b/integration_tests/tests/cross_zone_ingress_guard.rs @@ -56,6 +56,7 @@ async fn user_origin_inbox_call_rejected() -> Result<()> { vec![inbox_config_account_id(inbox_id), seen_id], vec![], Instruction::Dispatch(msg), + lee::FeeFields::ZERO, ) .expect("build dispatch message"); let tx = LeeTransaction::Public(PublicTransaction::new( diff --git a/integration_tests/tests/cross_zone_ping.rs b/integration_tests/tests/cross_zone_ping.rs index c4724b13b..25fa593d1 100644 --- a/integration_tests/tests/cross_zone_ping.rs +++ b/integration_tests/tests/cross_zone_ping.rs @@ -118,6 +118,7 @@ fn build_ping_tx(target_zone: [u8; 32], receiver_id: ProgramId) -> LeeTransactio vec![outbox_account], vec![], send, + lee::FeeFields::ZERO, ) .expect("build ping message"); LeeTransaction::Public(PublicTransaction::new( diff --git a/integration_tests/tests/cross_zone_state_machine.rs b/integration_tests/tests/cross_zone_state_machine.rs index b31ca070e..9490da6a6 100644 --- a/integration_tests/tests/cross_zone_state_machine.rs +++ b/integration_tests/tests/cross_zone_state_machine.rs @@ -145,6 +145,7 @@ fn inbox_dispatch_delivers_payload_to_ping_receiver() { vec![inbox_config_account_id(inbox_id), seen_id, record_id], vec![], InboxInstruction::Dispatch(msg), + lee::FeeFields::ZERO, ) .expect("build dispatch message"); let tx = PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![])); @@ -209,6 +210,7 @@ fn lock_escrows_balance_and_emits_to_outbox() { vec![holder_id, escrow_id, outbox_record_id], vec![0_u128.into()], lock, + lee::FeeFields::ZERO, ) .expect("build lock message"); let witness = WitnessSet::for_message(&message, &[&holder_key]); @@ -285,6 +287,7 @@ fn inbox_dispatch_mints_wrapped_token() { ], vec![], InboxInstruction::Dispatch(msg), + lee::FeeFields::ZERO, ) .expect("build dispatch message"); let tx = PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![])); @@ -352,6 +355,7 @@ fn a_mint_from_an_unrouted_emitter_is_rejected() { ], vec![], InboxInstruction::Dispatch(msg), + lee::FeeFields::ZERO, ) .expect("build dispatch message"); let tx = PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![])); @@ -409,6 +413,7 @@ fn a_mint_from_the_routed_emitter_is_accepted() { ], vec![], InboxInstruction::Dispatch(msg), + lee::FeeFields::ZERO, ) .expect("build dispatch message"); let tx = PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![])); @@ -487,6 +492,7 @@ fn mint_replay_rejected() { ], vec![], InboxInstruction::Dispatch(msg), + lee::FeeFields::ZERO, ) .expect("build dispatch message"); let tx = PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![])); diff --git a/integration_tests/tests/cross_zone_verified.rs b/integration_tests/tests/cross_zone_verified.rs index 92ccdacbe..4c2676b6d 100644 --- a/integration_tests/tests/cross_zone_verified.rs +++ b/integration_tests/tests/cross_zone_verified.rs @@ -123,6 +123,7 @@ fn build_ping_tx(target_zone: [u8; 32], receiver_id: ProgramId) -> LeeTransactio vec![outbox_account], vec![], send, + lee::FeeFields::ZERO, ) .expect("build ping message"); LeeTransaction::Public(PublicTransaction::new( diff --git a/integration_tests/tests/cross_zone_watcher_restart.rs b/integration_tests/tests/cross_zone_watcher_restart.rs index 86dfc7059..fd7ee46a5 100644 --- a/integration_tests/tests/cross_zone_watcher_restart.rs +++ b/integration_tests/tests/cross_zone_watcher_restart.rs @@ -204,6 +204,7 @@ fn build_ping_tx(target_zone: [u8; 32], receiver_id: ProgramId) -> LeeTransactio vec![outbox_account], vec![], send, + lee::FeeFields::ZERO, ) .expect("build ping message"); LeeTransaction::Public(PublicTransaction::new( diff --git a/integration_tests/tests/program_deployment.rs b/integration_tests/tests/program_deployment.rs index 3c620168e..68d122893 100644 --- a/integration_tests/tests/program_deployment.rs +++ b/integration_tests/tests/program_deployment.rs @@ -36,8 +36,13 @@ async fn deploy_and_execute_program() -> Result<()> { .wallet() .get_account_public_signing_key(account_id) .unwrap(); - let message = - lee::public_transaction::Message::try_new(claimer.id(), vec![account_id], nonces, ())?; + let message = lee::public_transaction::Message::try_new( + claimer.id(), + vec![account_id], + nonces, + (), + lee::FeeFields::ZERO, + )?; let witness_set = lee::public_transaction::WitnessSet::for_message(&message, &[private_key]); let transaction = lee::PublicTransaction::new(message, witness_set); let _response = ctx diff --git a/integration_tests/tests/tps.rs b/integration_tests/tests/tps.rs index ab08ffdc8..b8664917b 100644 --- a/integration_tests/tests/tps.rs +++ b/integration_tests/tests/tps.rs @@ -87,6 +87,7 @@ impl TpsTestManager { vec![*account_id, owner_vault_id], vec![Nonce(0_u128)], vault_core::Instruction::Claim { amount: 10 }, + lee::FeeFields::ZERO, ) .context("Failed to build vault claim message")?; let witness_set = @@ -137,6 +138,7 @@ impl TpsTestManager { [pair[0].1, pair[1].1].to_vec(), [Nonce(1_u128)].to_vec(), authenticated_transfer_core::Instruction::Transfer { amount }, + lee::FeeFields::ZERO, ) .unwrap(); let witness_set = diff --git a/lee/state_machine/src/encoding/program_deployment_transaction.rs b/lee/state_machine/src/encoding/program_deployment_transaction.rs index 05688086a..bac5f7dfc 100644 --- a/lee/state_machine/src/encoding/program_deployment_transaction.rs +++ b/lee/state_machine/src/encoding/program_deployment_transaction.rs @@ -1,4 +1,12 @@ -use crate::{ProgramDeploymentTransaction, error::LeeError}; +use crate::{ + ProgramDeploymentTransaction, error::LeeError, program_deployment_transaction::Message, +}; + +impl Message { + pub(crate) fn to_bytes(&self) -> Vec { + borsh::to_vec(&self).expect("Autoderived borsh serialization failure") + } +} impl ProgramDeploymentTransaction { #[must_use] @@ -13,14 +21,39 @@ impl ProgramDeploymentTransaction { #[cfg(test)] mod tests { - use crate::{ProgramDeploymentTransaction, program_deployment_transaction::Message}; + use crate::{ + AccountId, PrivateKey, ProgramDeploymentTransaction, fees::FeeFields, + program_deployment_transaction::Message, public_transaction::WitnessSet, + }; #[test] fn roundtrip() { - let message = Message::new(vec![0xca, 0xfe, 0xca, 0xfe, 0x01, 0x02, 0x03]); - let tx = ProgramDeploymentTransaction::new(message); + let message = Message::new_feeless(vec![0xca, 0xfe, 0xca, 0xfe, 0x01, 0x02, 0x03]); + let tx = ProgramDeploymentTransaction::new(message, WitnessSet::from_raw_parts(vec![])); let bytes = tx.to_bytes(); let tx_from_bytes = ProgramDeploymentTransaction::from_bytes(&bytes).unwrap(); assert_eq!(tx, tx_from_bytes); } + + /// Nonzero fee fields, a witness and a fee witness all survive + /// serialize -> deserialize -> hash unchanged. + #[test] + fn roundtrip_with_fee_fields_and_witnesses() { + let signer = PrivateKey::try_new([3; 32]).unwrap(); + let sponsor = PrivateKey::try_new([4; 32]).unwrap(); + let message = Message::new( + vec![0x7F, 0x45, 0x4C, 0x46], + FeeFields::new(AccountId::new([9; 32]), 21_000, 7, 1_000_000), + ); + let witness_set = + WitnessSet::for_message(&message, &[&signer]).with_fee_signer(&message, &sponsor); + let tx = ProgramDeploymentTransaction::new(message, witness_set); + + let tx_from_bytes = ProgramDeploymentTransaction::from_bytes(&tx.to_bytes()).unwrap(); + assert_eq!(tx, tx_from_bytes); + assert_eq!(tx.hash(), tx_from_bytes.hash()); + assert_eq!(tx.message().hash(), tx_from_bytes.message().hash()); + assert_eq!(tx_from_bytes.message().fees().gas_limit, 21_000); + assert!(tx_from_bytes.witness_set().fee_witness().is_some()); + } } diff --git a/lee/state_machine/src/fees.rs b/lee/state_machine/src/fees.rs new file mode 100644 index 000000000..72cafdccc --- /dev/null +++ b/lee/state_machine/src/fees.rs @@ -0,0 +1,279 @@ +//! Signed fee fields and payer authorization. +//! +//! The fee fields live inside the hashed/signed message of every fee-bearing +//! transaction, so any signature over the message hash covers them. This +//! module carries the shared pieces: the field group itself, the designated +//! system payer, and the authorization check both public and program-deployment +//! transactions run. +//! +//! Nothing here *charges* anything — assessment and enforcement are wired later +//! (T8); this only makes the fields carryable and the payer authorizable. + +use borsh::{BorshDeserialize, BorshSerialize}; +use lee_core::account::AccountId; + +use crate::public_transaction::WitnessSet; + +/// The payer a system transaction designates. +/// +/// System transactions (clock, bridge-deposit mints, cross-zone dispatches, +/// genesis actions) are fee-exempt and are recognized structurally, not by this +/// id; it exists so every message has one uniform shape. +pub const SYSTEM_PAYER: AccountId = AccountId::new([0_u8; 32]); + +/// The fee fields carried inside a signed message. +/// +/// Grouped only for construction; the fields are stored flat on each message so +/// their Borsh encoding is the plain concatenation +/// `payer ‖ gas_limit ‖ tip ‖ max_fee`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +pub struct FeeFields { + /// The fee account, always designated explicitly, never inferred from the + /// witness set. + pub payer: AccountId, + /// Execution bound; metering halts here. + pub gas_limit: u64, + /// Priority payment, MAY be 0. + pub tip: u64, + /// Signed cap on the fee reserve. + pub max_fee: u128, +} + +impl FeeFields { + /// Zeroed fee fields: the shape system transactions carry, and the shape + /// any transaction built without a fee intent carries. + pub const ZERO: Self = Self { + payer: SYSTEM_PAYER, + gas_limit: 0, + tip: 0, + max_fee: 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 that is hashed for signing and designates a fee payer. +/// +/// Implemented by the public-transaction and program-deployment messages so +/// both share one witness set, one signing path and one authorization check. +pub trait SignedMessage { + /// The 32-byte hash signers sign, domain-separated by the message's own + /// prefix. + fn signing_hash(&self) -> [u8; 32]; + + /// The explicitly designated fee payer. + fn payer(&self) -> AccountId; +} + +/// The account ids whose fee authorization accompanies `message`. +/// +/// Q1 (answered): authorization is an explicit designation plus a signature +/// over the fee fields and the exact transaction they cover. Both routes verify +/// against the same message hash — a witness signature (the payer is one of the +/// transaction's signers) or the dedicated fee witness (a sponsor outside the +/// witness set). Signatures that do not verify contribute nothing. +// TBA(Q1-program-auth): the ruling also allows a program authorization. Adding +// it means a third arm here and nowhere else. +#[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 payer `message` designates is authorized to pay its fees. +#[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::{FeeFields, SYSTEM_PAYER, fee_authorized_account_ids, is_fee_authorized}; + use crate::{ + AccountId, PrivateKey, PublicKey, Signature, program_deployment_transaction, + public_transaction::{Message, WitnessSet}, + }; + + fn key(seed: u8) -> PrivateKey { + PrivateKey::try_new([seed; 32]).unwrap() + } + + fn account_of(key: &PrivateKey) -> AccountId { + AccountId::from(&PublicKey::new_from_private_key(key)) + } + + /// A message signed by `signer`, designating `payer` and real fee amounts. + fn message_paid_by(payer: AccountId) -> Message { + Message::new_preserialized( + [1_u32; 8], + vec![account_of(&key(1)), account_of(&key(2))], + vec![0_u128.into(), 0_u128.into()], + vec![], + FeeFields::new(payer, 60_000, 100, 1_000_000), + ) + } + + #[test] + fn payer_that_is_a_signer_is_authorized() { + let (signer, other) = (key(1), key(2)); + let message = message_paid_by(account_of(&signer)); + let witness_set = WitnessSet::for_message(&message, &[&signer, &other]); + + assert!(is_fee_authorized(&message, &witness_set)); + assert_eq!( + fee_authorized_account_ids(&message, &witness_set), + vec![account_of(&signer), account_of(&other)] + ); + } + + #[test] + fn payer_outside_the_witness_set_is_rejected_without_a_fee_witness() { + let (signer, sponsor) = (key(1), key(3)); + let message = message_paid_by(account_of(&sponsor)); + let witness_set = WitnessSet::for_message(&message, &[&signer]); + + assert!(!is_fee_authorized(&message, &witness_set)); + } + + #[test] + fn sponsored_payer_with_a_fee_witness_is_authorized() { + let (signer, sponsor) = (key(1), key(3)); + let message = message_paid_by(account_of(&sponsor)); + let witness_set = + WitnessSet::for_message(&message, &[&signer]).with_fee_signer(&message, &sponsor); + + assert!(is_fee_authorized(&message, &witness_set)); + assert!(witness_set.is_valid_for(&message)); + } + + #[test] + fn fee_witness_for_a_different_account_does_not_authorize_the_payer() { + let (signer, sponsor, stranger) = (key(1), key(3), key(4)); + let message = message_paid_by(account_of(&sponsor)); + let witness_set = + WitnessSet::for_message(&message, &[&signer]).with_fee_signer(&message, &stranger); + + assert!(!is_fee_authorized(&message, &witness_set)); + } + + /// The fee witness carries the payer's public key but a signature made by + /// somebody else: it verifies against neither key, so it authorizes nobody. + #[test] + fn fee_witness_with_a_mismatched_public_key_is_rejected() { + let (signer, sponsor, forger) = (key(1), key(3), key(4)); + let message = message_paid_by(account_of(&sponsor)); + let mut witness_set = WitnessSet::for_message(&message, &[&signer]); + witness_set.fee_witness = Some(( + Signature::new(&forger, &message.hash()), + PublicKey::new_from_private_key(&sponsor), + )); + + assert!(!is_fee_authorized(&message, &witness_set)); + assert!(!witness_set.is_valid_for(&message)); + } + + /// A fee witness over a different message hash — e.g. lifted from another + /// transaction — does not carry over. + #[test] + fn fee_witness_over_another_message_is_rejected() { + let (signer, sponsor) = (key(1), key(3)); + let other_message = message_paid_by(account_of(&signer)); + let message = message_paid_by(account_of(&sponsor)); + let mut witness_set = WitnessSet::for_message(&message, &[&signer]); + witness_set.fee_witness = Some(( + Signature::new(&sponsor, &other_message.hash()), + PublicKey::new_from_private_key(&sponsor), + )); + + assert!(!is_fee_authorized(&message, &witness_set)); + } + + /// A witness signature that does not verify contributes no authorization, + /// even when its public key derives the designated payer. + #[test] + fn invalid_signer_signature_does_not_authorize() { + let signer = key(1); + let message = message_paid_by(account_of(&signer)); + let mut witness_set = WitnessSet::for_message(&message, &[&signer]); + witness_set.signatures_and_public_keys[0].0 = Signature::new_for_tests([1; 64]); + + assert!(!is_fee_authorized(&message, &witness_set)); + assert!(fee_authorized_account_ids(&message, &witness_set).is_empty()); + } + + #[test] + fn no_witnesses_authorize_nobody() { + let message = message_paid_by(account_of(&key(1))); + let witness_set = WitnessSet::from_raw_parts(vec![]); + + assert!(!is_fee_authorized(&message, &witness_set)); + assert!(fee_authorized_account_ids(&message, &witness_set).is_empty()); + } + + /// The zeroed fee fields system transactions carry designate the system + /// payer, which no key derives, so they are never fee-authorized. That is + /// intended: system transactions are fee-exempt and are recognized + /// structurally, not through this check. + #[test] + fn zeroed_fee_fields_designate_the_system_payer() { + assert_eq!(FeeFields::ZERO.payer, SYSTEM_PAYER); + let signer = key(1); + let message = Message::new_preserialized( + [1_u32; 8], + vec![account_of(&signer)], + vec![0_u128.into()], + vec![], + FeeFields::ZERO, + ); + let witness_set = WitnessSet::for_message(&message, &[&signer]); + + assert!(!is_fee_authorized(&message, &witness_set)); + } + + /// Deployments run the same authorization machinery as public transactions, + /// through the shared `SignedMessage` implementation. + #[test] + fn deployment_messages_use_the_same_authorization() { + let (deployer, sponsor, stranger) = (key(1), key(3), key(4)); + let message = program_deployment_transaction::Message::new( + vec![0x7F, 0x45, 0x4C, 0x46], + FeeFields::new(account_of(&sponsor), 60_000, 0, 1_000_000), + ); + + let unsponsored = WitnessSet::for_message(&message, &[&deployer]); + assert!(!is_fee_authorized(&message, &unsponsored)); + + let sponsored = unsponsored.clone().with_fee_signer(&message, &sponsor); + assert!(is_fee_authorized(&message, &sponsored)); + + let wrong_sponsor = unsponsored.with_fee_signer(&message, &stranger); + assert!(!is_fee_authorized(&message, &wrong_sponsor)); + + // Payer-is-a-signer works for deployments too. + let self_paid = program_deployment_transaction::Message::new( + vec![0x7F, 0x45, 0x4C, 0x46], + FeeFields::new(account_of(&deployer), 60_000, 0, 1_000_000), + ); + assert!(is_fee_authorized( + &self_paid, + &WitnessSet::for_message(&self_paid, &[&deployer]) + )); + } +} diff --git a/lee/state_machine/src/lib.rs b/lee/state_machine/src/lib.rs index 86e749786..24e4f465f 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}, @@ -23,6 +26,7 @@ pub use validated_state_diff::{ExecutionOutcome, 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/program_deployment_transaction/message.rs b/lee/state_machine/src/program_deployment_transaction/message.rs index 34c6a806a..6cf1a362e 100644 --- a/lee/state_machine/src/program_deployment_transaction/message.rs +++ b/lee/state_machine/src/program_deployment_transaction/message.rs @@ -1,41 +1,154 @@ use borsh::{BorshDeserialize, BorshSerialize}; +use lee_core::account::AccountId; +use sha2::{Digest as _, Sha256}; + +use crate::fees::{FeeFields, SignedMessage}; + +const PREFIX: &[u8; 32] = b"/LEE/v0.3/Message/Deployment/\x00\x00\x00"; #[derive(Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] pub struct Message { - pub(crate) bytecode: Vec, + pub bytecode: Vec, + /// Fee fields ([`FeeFields`]), inside the hashed content so every signature + /// over [`Message::hash`] covers them. Deployments are priced as public + /// transactions (tokenomics ruling Q2). + pub payer: AccountId, + pub gas_limit: u64, + pub tip: u64, + pub max_fee: u128, } impl std::fmt::Debug for Message { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Message") .field("bytecode", &format_args!("<{} bytes>", self.bytecode.len())) + .field("payer", &self.payer) + .field("gas_limit", &self.gas_limit) + .field("tip", &self.tip) + .field("max_fee", &self.max_fee) .finish() } } impl Message { #[must_use] - pub const fn new(bytecode: Vec) -> Self { - Self { bytecode } + pub const fn new(bytecode: Vec, fees: FeeFields) -> Self { + let FeeFields { + payer, + gas_limit, + tip, + max_fee, + } = fees; + Self { + bytecode, + payer, + gas_limit, + tip, + max_fee, + } } #[must_use] pub fn into_bytecode(self) -> Vec { self.bytecode } + + /// The message's fee fields, regrouped. + #[must_use] + pub const fn fees(&self) -> FeeFields { + FeeFields::new(self.payer, self.gas_limit, self.tip, self.max_fee) + } + + /// Test-only shorthand for [`Self::new`] with zeroed fee fields. Gated for the same reason + /// as its public-transaction counterpart: paying no fee is a decision, not a default. + #[cfg(any(test, feature = "test-utils"))] + #[must_use] + pub const fn new_feeless(bytecode: Vec) -> Self { + Self::new(bytecode, FeeFields::ZERO) + } + + #[must_use] + pub fn hash(&self) -> [u8; 32] { + let bytes = self.to_bytes(); + let mut hasher = Sha256::new(); + hasher.update(PREFIX); + hasher.update(&bytes); + hasher.finalize().into() + } +} + +impl SignedMessage for Message { + fn signing_hash(&self) -> [u8; 32] { + self.hash() + } + + fn payer(&self) -> AccountId { + self.payer + } } #[cfg(test)] mod tests { - use super::Message; + use sha2::{Digest as _, Sha256}; + + use super::{FeeFields, Message, PREFIX}; + use crate::AccountId; #[test] fn bytecode_roundtrip() { // `Message::new(b).into_bytecode()` must return exactly `b`. Catches // mutations of `into_bytecode` returning `vec![]`, `vec![0]`, or `vec![1]`. let bytecode = vec![0x7F_u8, 0x45, 0x4C, 0x46]; // ELF magic - assert_eq!(Message::new(bytecode.clone()).into_bytecode(), bytecode); - assert!(Message::new(vec![]).into_bytecode().is_empty()); - assert_eq!(Message::new(vec![0xAB]).into_bytecode(), vec![0xAB_u8]); + assert_eq!( + Message::new(bytecode.clone(), FeeFields::ZERO).into_bytecode(), + bytecode + ); + assert!( + Message::new(vec![], FeeFields::ZERO) + .into_bytecode() + .is_empty() + ); + assert_eq!( + Message::new(vec![0xAB], FeeFields::ZERO).into_bytecode(), + vec![0xAB_u8] + ); + } + + /// Pinned when deployment messages gained a hash: prefix + /// `/LEE/v0.3/Message/Deployment/`, then the four fee fields appended after + /// the bytecode. The version tag matches the other message domains. + #[test] + fn hash_deployment_pinned() { + let msg = Message::new( + vec![0x7F_u8, 0x45, 0x4C, 0x46], + FeeFields::new(AccountId::new([7_u8; 32]), 0x0102_0304, 9, 0x0a0b), + ); + + let expected_borsh: Vec = [ + &[4_u8, 0, 0, 0][..], // bytecode len = 4 + &[0x7F, 0x45, 0x4C, 0x46], // bytecode + &[7_u8; 32], // payer + &[4, 3, 2, 1, 0, 0, 0, 0], // gas_limit u64 LE + &[9, 0, 0, 0, 0, 0, 0, 0], // tip u64 LE + &[0x0b, 0x0a, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], // max_fee u128 LE + ] + .concat(); + + assert_eq!( + borsh::to_vec(&msg).unwrap(), + expected_borsh, + "`program_deployment_transaction::hash()`: expected borsh order has changed" + ); + + let mut preimage = Vec::with_capacity(PREFIX.len() + expected_borsh.len()); + preimage.extend_from_slice(PREFIX); + preimage.extend_from_slice(&expected_borsh); + let expected_hash: [u8; 32] = Sha256::digest(&preimage).into(); + + assert_eq!( + msg.hash(), + expected_hash, + "`program_deployment_transaction::hash()`: serialization has changed" + ); } } diff --git a/lee/state_machine/src/program_deployment_transaction/transaction.rs b/lee/state_machine/src/program_deployment_transaction/transaction.rs index 890d6c3e4..529440d27 100644 --- a/lee/state_machine/src/program_deployment_transaction/transaction.rs +++ b/lee/state_machine/src/program_deployment_transaction/transaction.rs @@ -2,17 +2,33 @@ use borsh::{BorshDeserialize, BorshSerialize}; use lee_core::account::AccountId; use sha2::{Digest as _, digest::FixedOutput as _}; -use crate::program_deployment_transaction::message::Message; +use crate::{program_deployment_transaction::message::Message, public_transaction::WitnessSet}; #[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] pub struct ProgramDeploymentTransaction { pub message: Message, + /// Same witness machinery as a public transaction: witness signatures over + /// the message hash, plus an optional fee witness for a sponsoring payer. + pub witness_set: WitnessSet, } impl ProgramDeploymentTransaction { #[must_use] - pub const fn new(message: Message) -> Self { - Self { message } + pub const fn new(message: Message, witness_set: WitnessSet) -> Self { + Self { + message, + witness_set, + } + } + + #[must_use] + pub const fn message(&self) -> &Message { + &self.message + } + + #[must_use] + pub const fn witness_set(&self) -> &WitnessSet { + &self.witness_set } #[must_use] diff --git a/lee/state_machine/src/public_transaction/message.rs b/lee/state_machine/src/public_transaction/message.rs index feafc5391..eec0f9c62 100644 --- a/lee/state_machine/src/public_transaction/message.rs +++ b/lee/state_machine/src/public_transaction/message.rs @@ -6,7 +6,12 @@ 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, SignedMessage}, + program::Program, +}; const PREFIX: &[u8; 32] = b"/LEE/v0.3/Message/Public/\x00\x00\x00\x00\x00\x00\x00"; @@ -16,6 +21,12 @@ pub struct Message { pub account_ids: Vec, pub nonces: Vec, pub instruction_data: InstructionData, + /// Fee fields ([`FeeFields`]), inside the hashed content so every signature + /// over [`Message::hash`] covers them. + pub payer: AccountId, + pub gas_limit: u64, + pub tip: u64, + pub max_fee: u128, } impl std::fmt::Debug for Message { @@ -31,6 +42,10 @@ 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() } } @@ -41,15 +56,17 @@ impl Message { 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 +75,59 @@ impl Message { account_ids: Vec, nonces: Vec, instruction_data: InstructionData, + fees: FeeFields, ) -> Self { + let FeeFields { + payer, + gas_limit, + tip, + max_fee, + } = fees; Self { program_id, account_ids, nonces, instruction_data, + payer, + gas_limit, + tip, + max_fee, } } + /// The message's fee fields, regrouped. + #[must_use] + pub const fn fees(&self) -> FeeFields { + FeeFields::new(self.payer, self.gas_limit, self.tip, self.max_fee) + } + + /// Test-only shorthand for [`Self::try_new`] with zeroed fee fields, unwrapped. + /// + /// Gated so production code cannot reach it: a transaction that pays no fee is a protocol + /// decision, never a default. System transactions spell [`FeeFields::ZERO`] out at their + /// construction sites for exactly that reason. + /// + /// # Panics + /// + /// If `instruction` fails to serialize. + #[cfg(any(test, feature = "test-utils"))] + #[must_use] + pub fn new_feeless( + program_id: ProgramId, + account_ids: Vec, + nonces: Vec, + instruction: T, + ) -> Self { + Self::try_new( + program_id, + account_ids, + nonces, + instruction, + FeeFields::ZERO, + ) + .expect("instruction should serialize") + } + #[must_use] pub fn hash(&self) -> [u8; 32] { let mut bytes = Vec::with_capacity( @@ -82,13 +143,25 @@ impl Message { } } +impl 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}; use sha2::{Digest as _, Sha256}; - use super::{Message, PREFIX}; + use super::{FeeFields, Message, PREFIX}; + /// Re-pinned when the fee fields were added: the four of them are appended + /// after `instruction_data`. The domain prefix is unchanged. #[test] fn hash_public_pinned() { let msg = Message::new_preserialized( @@ -96,6 +169,7 @@ mod tests { vec![AccountId::new([42_u8; 32])], vec![Nonce(5)], vec![], + FeeFields::new(AccountId::new([7_u8; 32]), 0x0102_0304, 9, 0x0a0b), ); // program_id: [1_u32; 8], each word as LE u32 @@ -108,6 +182,12 @@ 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]; + // payer: AccountId([7_u8; 32]) + let payer_bytes: &[u8] = &[7_u8; 32]; + // gas_limit: u64 LE, tip: u64 LE, max_fee: u128 LE + let gas_limit_bytes: &[u8] = &[4, 3, 2, 1, 0, 0, 0, 0]; + let tip_bytes: &[u8] = &[9, 0, 0, 0, 0, 0, 0, 0]; + let max_fee_bytes: &[u8] = &[0x0b, 0x0a, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; let expected_borsh_vec: Vec = [ program_id_bytes, @@ -115,6 +195,10 @@ mod tests { account_ids_bytes, nonces_bytes, instruction_data_bytes, + payer_bytes, + gas_limit_bytes, + tip_bytes, + max_fee_bytes, ] .concat(); let expected_borsh: &[u8] = &expected_borsh_vec; diff --git a/lee/state_machine/src/public_transaction/mod.rs b/lee/state_machine/src/public_transaction/mod.rs index 1af61e109..3c1712b27 100644 --- a/lee/state_machine/src/public_transaction/mod.rs +++ b/lee/state_machine/src/public_transaction/mod.rs @@ -1,6 +1,6 @@ pub use message::Message; pub use transaction::PublicTransaction; -pub use witness_set::WitnessSet; +pub use witness_set::{Witness, WitnessSet}; mod message; mod transaction; diff --git a/lee/state_machine/src/public_transaction/transaction.rs b/lee/state_machine/src/public_transaction/transaction.rs index 4a4f28622..97e063e81 100644 --- a/lee/state_machine/src/public_transaction/transaction.rs +++ b/lee/state_machine/src/public_transaction/transaction.rs @@ -91,13 +91,12 @@ pub mod tests { let (key1, key2, addr1, addr2) = keys_for_tests(); let nonces = vec![0_u128.into(), 0_u128.into()]; let instruction = 1337; - let message = Message::try_new( + let message = Message::new_feeless( crate::test_methods::simple_balance_transfer().id(), vec![addr1, addr2], nonces, instruction, - ) - .unwrap(); + ); let witness_set = WitnessSet::for_message(&message, &[&key1, &key2]); PublicTransaction::new(message, witness_set) @@ -150,6 +149,64 @@ pub mod tests { assert_eq!(tx, tx_from_bytes); } + /// Nonzero fee fields and a fee witness survive serialize -> deserialize + /// -> hash unchanged. The message hash matters most: the fee fields are + /// inside the signed content, so any drift invalidates every signature. + #[test] + fn roundtrip_preserves_nonzero_fee_fields() { + let (key1, key2, addr1, addr2) = keys_for_tests(); + let sponsor = PrivateKey::try_new([9; 32]).unwrap(); + let sponsor_id = AccountId::from(&PublicKey::new_from_private_key(&sponsor)); + let fees = crate::FeeFields::new(sponsor_id, 60_000, 100, 10_u128.pow(9)); + let message = Message::try_new( + crate::test_methods::simple_balance_transfer().id(), + vec![addr1, addr2], + vec![0_u128.into(), 0_u128.into()], + 1337, + fees, + ) + .unwrap(); + let witness_set = + WitnessSet::for_message(&message, &[&key1, &key2]).with_fee_signer(&message, &sponsor); + let tx = PublicTransaction::new(message, witness_set); + + let tx_from_bytes = PublicTransaction::from_bytes(&tx.to_bytes()).unwrap(); + assert_eq!(tx, tx_from_bytes); + assert_eq!(tx.hash(), tx_from_bytes.hash()); + assert_eq!(tx.message().hash(), tx_from_bytes.message().hash()); + assert_eq!(tx_from_bytes.message().fees(), fees); + assert!( + tx_from_bytes + .witness_set() + .is_valid_for(tx_from_bytes.message()) + ); + assert!(crate::is_fee_authorized( + tx_from_bytes.message(), + tx_from_bytes.witness_set() + )); + } + + /// The fee fields are part of the signed content: change one and the + /// existing signatures no longer verify. + #[test] + fn changing_a_fee_field_invalidates_the_signatures() { + let (key1, key2, addr1, addr2) = keys_for_tests(); + let message = Message::try_new( + crate::test_methods::simple_balance_transfer().id(), + vec![addr1, addr2], + vec![0_u128.into(), 0_u128.into()], + 1337, + crate::FeeFields::new(addr1, 60_000, 100, 10_u128.pow(9)), + ) + .unwrap(); + let witness_set = WitnessSet::for_message(&message, &[&key1, &key2]); + assert!(witness_set.is_valid_for(&message)); + + let mut tampered = message; + tampered.tip = tampered.tip.checked_add(1).unwrap(); + assert!(!witness_set.is_valid_for(&tampered)); + } + #[test] fn hash_is_sha256_of_transaction_bytes() { let tx = transaction_for_tests(); @@ -169,13 +226,12 @@ pub mod tests { let state = state_for_tests(); let nonces = vec![0_u128.into(), 0_u128.into()]; let instruction = 1337; - let message = Message::try_new( + let message = Message::new_feeless( crate::test_methods::simple_balance_transfer().id(), vec![addr1, addr1], nonces, instruction, - ) - .unwrap(); + ); let witness_set = WitnessSet::for_message(&message, &[&key1, &key1]); let tx = PublicTransaction::new(message, witness_set); @@ -189,13 +245,12 @@ pub mod tests { let state = state_for_tests(); let nonces = vec![0_u128.into()]; let instruction = 1337; - let message = Message::try_new( + let message = Message::new_feeless( crate::test_methods::simple_balance_transfer().id(), vec![addr1, addr2], nonces, instruction, - ) - .unwrap(); + ); let witness_set = WitnessSet::for_message(&message, &[&key1, &key2]); let tx = PublicTransaction::new(message, witness_set); @@ -209,13 +264,12 @@ pub mod tests { let state = state_for_tests(); let nonces = vec![0_u128.into(), 0_u128.into()]; let instruction = 1337; - let message = Message::try_new( + let message = Message::new_feeless( crate::test_methods::simple_balance_transfer().id(), vec![addr1, addr2], nonces, instruction, - ) - .unwrap(); + ); let mut witness_set = WitnessSet::for_message(&message, &[&key1, &key2]); witness_set.signatures_and_public_keys[0].0 = Signature::new_for_tests([1; 64]); @@ -230,13 +284,12 @@ pub mod tests { let state = state_for_tests(); let nonces = vec![0_u128.into(), 1_u128.into()]; let instruction = 1337; - let message = Message::try_new( + let message = Message::new_feeless( crate::test_methods::simple_balance_transfer().id(), vec![addr1, addr2], nonces, instruction, - ) - .unwrap(); + ); let witness_set = WitnessSet::for_message(&message, &[&key1, &key2]); let tx = PublicTransaction::new(message, witness_set); @@ -252,6 +305,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); @@ -267,7 +321,7 @@ pub mod tests { let instruction = 1337; let unknown_program_id = [0xdead_beef; 8]; let message = - Message::try_new(unknown_program_id, vec![addr1, addr2], nonces, instruction).unwrap(); + Message::new_feeless(unknown_program_id, vec![addr1, addr2], nonces, instruction); let witness_set = WitnessSet::for_message(&message, &[&key1, &key2]); 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..9d2fc33ba 100644 --- a/lee/state_machine/src/public_transaction/witness_set.rs +++ b/lee/state_machine/src/public_transaction/witness_set.rs @@ -1,16 +1,23 @@ use borsh::{BorshDeserialize, BorshSerialize}; -use crate::{PrivateKey, PublicKey, Signature, public_transaction::Message}; +use crate::{PrivateKey, PublicKey, Signature, fees::SignedMessage}; + +/// One witness: a signature and the public key it verifies under. +pub type Witness = (Signature, PublicKey); #[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] pub struct WitnessSet { pub(crate) signatures_and_public_keys: Vec<(Signature, PublicKey)>, + /// Authorization for a payer outside the witness set (a sponsored + /// transaction): the payer's public key and its signature over the same + /// message hash the other witnesses sign. + pub(crate) fee_witness: Option<(Signature, PublicKey)>, } impl WitnessSet { #[must_use] - pub fn for_message(message: &Message, private_keys: &[&PrivateKey]) -> Self { - let message_hash = message.hash(); + pub fn for_message(message: &M, private_keys: &[&PrivateKey]) -> Self { + let message_hash = message.signing_hash(); let signatures_and_public_keys = private_keys .iter() .map(|&key| { @@ -22,18 +29,34 @@ impl WitnessSet { .collect(); Self { signatures_and_public_keys, + fee_witness: None, } } + /// Adds the fee witness for a sponsoring payer that is not among the + /// transaction's signers. #[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() { - if !signature.is_valid_for(&message_hash, public_key) { - return false; - } - } - true + pub fn with_fee_signer( + mut self, + message: &M, + payer_key: &PrivateKey, + ) -> Self { + self.fee_witness = Some(( + Signature::new(payer_key, &message.signing_hash()), + PublicKey::new_from_private_key(payer_key), + )); + self + } + + /// Every signature present — witness signatures and the fee witness alike — + /// verifies against `message`'s hash. + #[must_use] + pub fn is_valid_for(&self, message: &M) -> bool { + let message_hash = message.signing_hash(); + self.signatures_and_public_keys() + .iter() + .chain(self.fee_witness()) + .all(|(signature, public_key)| signature.is_valid_for(&message_hash, public_key)) } #[must_use] @@ -42,14 +65,34 @@ impl WitnessSet { } #[must_use] - pub fn into_raw_parts(self) -> Vec<(Signature, PublicKey)> { - self.signatures_and_public_keys + pub const fn fee_witness(&self) -> Option<&Witness> { + self.fee_witness.as_ref() } + /// The exact inverse of [`Self::from_parts`]: returning the witness signatures alone would + /// drop a sponsor's fee authorization on every round trip, with nothing to catch it. + #[must_use] + pub fn into_raw_parts(self) -> (Vec, Option) { + (self.signatures_and_public_keys, self.fee_witness) + } + + /// Shorthand for [`Self::from_parts`] with no fee witness. #[must_use] pub const fn from_raw_parts(signatures_and_public_keys: Vec<(Signature, PublicKey)>) -> Self { Self { signatures_and_public_keys, + fee_witness: None, + } + } + + #[must_use] + pub const fn from_parts( + signatures_and_public_keys: Vec, + fee_witness: Option, + ) -> Self { + Self { + signatures_and_public_keys, + fee_witness, } } } @@ -57,7 +100,7 @@ impl WitnessSet { #[cfg(test)] mod tests { use super::*; - use crate::AccountId; + use crate::{AccountId, public_transaction::Message}; #[test] fn for_message_constructor() { @@ -69,11 +112,12 @@ mod tests { let addr2 = AccountId::from(&pubkey2); let nonces = vec![1_u128.into(), 2_u128.into()]; let instruction = vec![1, 2, 3, 4]; - let message = Message::try_new([0; 8], vec![addr1, addr2], nonces, instruction).unwrap(); + let message = Message::new_feeless([0; 8], vec![addr1, addr2], nonces, instruction); let witness_set = WitnessSet::for_message(&message, &[&key1, &key2]); assert_eq!(witness_set.signatures_and_public_keys.len(), 2); + assert!(witness_set.fee_witness().is_none()); let message_bytes = message.hash(); for ((signature, public_key), expected_public_key) in witness_set @@ -85,4 +129,48 @@ mod tests { assert!(signature.is_valid_for(&message_bytes, &expected_public_key)); } } + + /// `into_raw_parts` must be the exact inverse of `from_parts`. A version that returned only + /// the witness signatures would silently destroy a sponsor's fee authorization here. + #[test] + fn raw_parts_roundtrip_preserves_the_fee_witness() { + let signer = PrivateKey::try_new([1; 32]).unwrap(); + let sponsor = PrivateKey::try_new([3; 32]).unwrap(); + let message = Message::new_feeless( + [0; 8], + vec![AccountId::from(&PublicKey::new_from_private_key(&signer))], + vec![0_u128.into()], + vec![1, 2, 3, 4], + ); + + let original = + WitnessSet::for_message(&message, &[&signer]).with_fee_signer(&message, &sponsor); + assert!(original.fee_witness().is_some()); + + let (signatures_and_public_keys, fee_witness) = original.clone().into_raw_parts(); + let rebuilt = WitnessSet::from_parts(signatures_and_public_keys, fee_witness); + + assert_eq!(rebuilt, original); + assert_eq!( + rebuilt.fee_witness(), + original.fee_witness(), + "the fee witness must survive a raw-parts round trip" + ); + } + + #[test] + fn raw_parts_roundtrip_without_a_fee_witness() { + let signer = PrivateKey::try_new([1; 32]).unwrap(); + let original = WitnessSet::from_raw_parts(vec![( + Signature::new_for_tests([7; 64]), + PublicKey::new_from_private_key(&signer), + )]); + + let (signatures_and_public_keys, fee_witness) = original.clone().into_raw_parts(); + assert!(fee_witness.is_none()); + assert_eq!( + WitnessSet::from_parts(signatures_and_public_keys, fee_witness), + original + ); + } } diff --git a/lee/state_machine/src/state/tests/changer_claimer.rs b/lee/state_machine/src/state/tests/changer_claimer.rs index b422d1199..f94fd5a89 100644 --- a/lee/state_machine/src/state/tests/changer_claimer.rs +++ b/lee/state_machine/src/state/tests/changer_claimer.rs @@ -12,8 +12,7 @@ fn public_changer_claimer_no_data_change_no_claim_succeeds() { let instruction: (Option>, bool) = (None, false); let message = - public_transaction::Message::try_new(program_id, vec![account_id], vec![], instruction) - .unwrap(); + public_transaction::Message::new_feeless(program_id, vec![account_id], vec![], instruction); let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); let tx = PublicTransaction::new(message, witness_set); @@ -38,8 +37,7 @@ fn public_changer_claimer_data_change_no_claim_fails() { let instruction: (Option>, bool) = (Some(new_data), false); let message = - public_transaction::Message::try_new(program_id, vec![account_id], vec![], instruction) - .unwrap(); + public_transaction::Message::new_feeless(program_id, vec![account_id], vec![], instruction); let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); let tx = PublicTransaction::new(message, witness_set); diff --git a/lee/state_machine/src/state/tests/claiming.rs b/lee/state_machine/src/state/tests/claiming.rs index 1d63fdb36..392fe7cc5 100644 --- a/lee/state_machine/src/state/tests/claiming.rs +++ b/lee/state_machine/src/state/tests/claiming.rs @@ -24,13 +24,12 @@ fn claiming_mechanism() { ..Account::default() }; - let message = public_transaction::Message::try_new( + let message = public_transaction::Message::new_feeless( program.id(), vec![from, to], vec![Nonce(0), Nonce(0)], amount, - ) - .unwrap(); + ); let witness_set = public_transaction::WitnessSet::for_message(&message, &[&from_key, &to_key]); let tx = PublicTransaction::new(message, witness_set); @@ -51,8 +50,7 @@ fn unauthorized_public_account_claiming_fails() { assert_eq!(state.get_account_by_id(account_id), Account::default()); let message = - public_transaction::Message::try_new(program.id(), vec![account_id], vec![], 0_u128) - .unwrap(); + public_transaction::Message::new_feeless(program.id(), vec![account_id], vec![], 0_u128); let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); let tx = PublicTransaction::new(message, witness_set); @@ -71,13 +69,12 @@ fn authorized_public_account_claiming_succeeds() { assert_eq!(state.get_account_by_id(account_id), Account::default()); - let message = public_transaction::Message::try_new( + let message = public_transaction::Message::new_feeless( program.id(), vec![account_id], vec![Nonce(0)], 0_u128, - ) - .unwrap(); + ); let witness_set = public_transaction::WitnessSet::for_message(&message, &[&account_key]); let tx = PublicTransaction::new(message, witness_set); @@ -119,14 +116,13 @@ fn public_chained_call() { ..Account::default() }; - let message = public_transaction::Message::try_new( + let message = public_transaction::Message::new_feeless( program.id(), vec![to, from], // The chain_caller program permutes the account order in the chain // call vec![Nonce(0)], instruction, - ) - .unwrap(); + ); let witness_set = public_transaction::WitnessSet::for_message(&message, &[&from_key]); let tx = PublicTransaction::new(message, witness_set); @@ -159,14 +155,13 @@ fn execution_fails_if_chained_calls_exceeds_depth() { None, ); - let message = public_transaction::Message::try_new( + let message = public_transaction::Message::new_feeless( program.id(), vec![to, from], // The chain_caller program permutes the account order in the chain // call vec![Nonce(0)], instruction, - ) - .unwrap(); + ); let witness_set = public_transaction::WitnessSet::for_message(&message, &[&from_key]); let tx = PublicTransaction::new(message, witness_set); @@ -201,14 +196,13 @@ fn execution_that_requires_authentication_of_a_program_derived_account_id_succee balance: amount, // The `chain_caller` chains the program twice ..Account::default() }; - let message = public_transaction::Message::try_new( + let message = public_transaction::Message::new_feeless( chain_caller.id(), vec![to, from], // The chain_caller program permutes the account order in the chain // call vec![], instruction, - ) - .unwrap(); + ); let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); let tx = PublicTransaction::new(message, witness_set); @@ -258,14 +252,13 @@ fn claiming_mechanism_within_chain_call() { 1, None, ); - let message = public_transaction::Message::try_new( + let message = public_transaction::Message::new_feeless( chain_caller.id(), vec![to, from], // The chain_caller program permutes the account order in the chain // call vec![Nonce(0), Nonce(0)], instruction, - ) - .unwrap(); + ); let witness_set = public_transaction::WitnessSet::for_message(&message, &[&from_key, &to_key]); let tx = PublicTransaction::new(message, witness_set); @@ -516,7 +509,7 @@ fn claiming_mechanism_cannot_claim_initialied_accounts() { ); let message = - public_transaction::Message::try_new(claimer.id(), vec![account_id], vec![], ()).unwrap(); + public_transaction::Message::new_feeless(claimer.id(), vec![account_id], vec![], ()); let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); let tx = PublicTransaction::new(message, witness_set); @@ -574,13 +567,12 @@ fn malicious_program_cannot_break_balance_validation_if_not_in_genesis() { let _recipient = AccountWithMetadata::new(state.get_account_by_id(recipient_id), false, sender_id); - let message = public_transaction::Message::try_new( + let message = public_transaction::Message::new_feeless( modified_transfer_id, vec![sender_id, recipient_id], vec![sender_nonce], balance_to_move, - ) - .unwrap(); + ); let witness_set = public_transaction::WitnessSet::for_message(&message, &[&sender_key]); let tx = PublicTransaction::new(message, witness_set); diff --git a/lee/state_machine/src/state/tests/flash_swap.rs b/lee/state_machine/src/state/tests/flash_swap.rs index be8f1c106..f8ff77b0a 100644 --- a/lee/state_machine/src/state/tests/flash_swap.rs +++ b/lee/state_machine/src/state/tests/flash_swap.rs @@ -179,9 +179,12 @@ fn flash_swap_standalone_invariant_check_rejected() { min_vault_balance: 1000, }; - let message = - public_transaction::Message::try_new(initiator.id(), vec![vault_id], vec![], instruction) - .unwrap(); + let message = public_transaction::Message::new_feeless( + initiator.id(), + vec![vault_id], + vec![], + instruction, + ); let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); let tx = PublicTransaction::new(message, witness_set); @@ -201,8 +204,7 @@ fn malicious_self_program_id_rejected_in_public_execution() { let mut state = V03State::new().with_test_programs(); state.force_insert_account(acc_id, account); - let message = - public_transaction::Message::try_new(program.id(), vec![acc_id], vec![], ()).unwrap(); + let message = public_transaction::Message::new_feeless(program.id(), vec![acc_id], vec![], ()); let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); let tx = PublicTransaction::new(message, witness_set); @@ -222,8 +224,7 @@ fn malicious_caller_program_id_rejected_in_public_execution() { let mut state = V03State::new().with_test_programs(); state.force_insert_account(acc_id, account); - let message = - public_transaction::Message::try_new(program.id(), vec![acc_id], vec![], ()).unwrap(); + let message = public_transaction::Message::new_feeless(program.id(), vec![acc_id], vec![], ()); let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); let tx = PublicTransaction::new(message, witness_set); diff --git a/lee/state_machine/src/state/tests/mod.rs b/lee/state_machine/src/state/tests/mod.rs index b1c67b9b5..db391cc13 100644 --- a/lee/state_machine/src/state/tests/mod.rs +++ b/lee/state_machine/src/state/tests/mod.rs @@ -209,7 +209,7 @@ fn transfer_transaction( let nonces = vec![Nonce(from_nonce), Nonce(to_nonce)]; let program_id = crate::test_methods::simple_balance_transfer().id(); let message = - public_transaction::Message::try_new(program_id, account_ids, nonces, balance).unwrap(); + public_transaction::Message::new_feeless(program_id, account_ids, nonces, balance); let witness_set = public_transaction::WitnessSet::for_message(&message, &[from_key, to_key]); PublicTransaction::new(message, witness_set) } @@ -220,13 +220,12 @@ fn build_flash_swap_tx( receiver_id: AccountId, instruction: FlashSwapInstruction, ) -> PublicTransaction { - let message = public_transaction::Message::try_new( + let message = public_transaction::Message::new_feeless( initiator.id(), vec![vault_id, receiver_id], vec![], // no signers — vault is PDA-authorised instruction, - ) - .unwrap(); + ); let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); PublicTransaction::new(message, witness_set) } diff --git a/lee/state_machine/src/state/tests/public_program_rules.rs b/lee/state_machine/src/state/tests/public_program_rules.rs index 236bddcff..fad61a2b3 100644 --- a/lee/state_machine/src/state/tests/public_program_rules.rs +++ b/lee/state_machine/src/state/tests/public_program_rules.rs @@ -8,8 +8,7 @@ fn program_should_fail_if_modifies_nonces() { .with_test_programs(); let account_ids = vec![account_id]; let program_id = crate::test_methods::nonce_changer().id(); - let message = - public_transaction::Message::try_new(program_id, account_ids, vec![], ()).unwrap(); + let message = public_transaction::Message::new_feeless(program_id, account_ids, vec![], ()); let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); let tx = PublicTransaction::new(message, witness_set); @@ -32,8 +31,7 @@ fn program_should_fail_if_output_accounts_exceed_inputs() { .with_test_programs(); let account_ids = vec![AccountId::new([1; 32])]; let program_id = crate::test_methods::extra_output().id(); - let message = - public_transaction::Message::try_new(program_id, account_ids, vec![], ()).unwrap(); + let message = public_transaction::Message::new_feeless(program_id, account_ids, vec![], ()); let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); let tx = PublicTransaction::new(message, witness_set); @@ -59,8 +57,7 @@ fn program_should_fail_with_missing_output_accounts() { .with_test_programs(); let account_ids = vec![AccountId::new([1; 32]), AccountId::new([2; 32])]; let program_id = crate::test_methods::missing_output().id(); - let message = - public_transaction::Message::try_new(program_id, account_ids, vec![], ()).unwrap(); + let message = public_transaction::Message::new_feeless(program_id, account_ids, vec![], ()); let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); let tx = PublicTransaction::new(message, witness_set); @@ -113,8 +110,7 @@ fn program_should_fail_if_it_drops_a_declared_account() { .with_test_programs(); let account_ids = vec![AccountId::new([1; 32]), AccountId::new([2; 32])]; let program_id = crate::test_methods::dropped_account().id(); - let message = - public_transaction::Message::try_new(program_id, account_ids, vec![], ()).unwrap(); + let message = public_transaction::Message::new_feeless(program_id, account_ids, vec![], ()); let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); let tx = PublicTransaction::new(message, witness_set); @@ -153,7 +149,7 @@ fn program_should_fail_if_modifies_program_owner_with_only_non_default_program_o assert_eq!(account.data, Account::default().data); let program_id = crate::test_methods::program_owner_changer().id(); let message = - public_transaction::Message::try_new(program_id, vec![account_id], vec![], ()).unwrap(); + public_transaction::Message::new_feeless(program_id, vec![account_id], vec![], ()); let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); let tx = PublicTransaction::new(message, witness_set); @@ -183,7 +179,7 @@ fn program_should_fail_if_modifies_program_owner_with_only_non_default_balance() assert_eq!(account.data, Account::default().data); let program_id = crate::test_methods::program_owner_changer().id(); let message = - public_transaction::Message::try_new(program_id, vec![account_id], vec![], ()).unwrap(); + public_transaction::Message::new_feeless(program_id, vec![account_id], vec![], ()); let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); let tx = PublicTransaction::new(message, witness_set); @@ -213,7 +209,7 @@ fn program_should_fail_if_modifies_program_owner_with_only_non_default_nonce() { assert_eq!(account.data, Account::default().data); let program_id = crate::test_methods::program_owner_changer().id(); let message = - public_transaction::Message::try_new(program_id, vec![account_id], vec![], ()).unwrap(); + public_transaction::Message::new_feeless(program_id, vec![account_id], vec![], ()); let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); let tx = PublicTransaction::new(message, witness_set); @@ -243,7 +239,7 @@ fn program_should_fail_if_modifies_program_owner_with_only_non_default_data() { assert_ne!(account.data, Account::default().data); let program_id = crate::test_methods::program_owner_changer().id(); let message = - public_transaction::Message::try_new(program_id, vec![account_id], vec![], ()).unwrap(); + public_transaction::Message::new_feeless(program_id, vec![account_id], vec![], ()); let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); let tx = PublicTransaction::new(message, witness_set); @@ -270,13 +266,12 @@ fn program_should_fail_if_transfers_balance_from_non_owned_account() { state.get_account_by_id(sender_account_id).program_owner, program_id ); - let message = public_transaction::Message::try_new( + let message = public_transaction::Message::new_feeless( program_id, vec![sender_account_id, receiver_account_id], vec![], balance_to_move, - ) - .unwrap(); + ); let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); let tx = PublicTransaction::new(message, witness_set); @@ -306,8 +301,7 @@ fn program_should_fail_if_modifies_data_of_non_owned_account() { program_id ); let message = - public_transaction::Message::try_new(program_id, vec![account_id], vec![], vec![0]) - .unwrap(); + public_transaction::Message::new_feeless(program_id, vec![account_id], vec![], vec![0]); let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); let tx = PublicTransaction::new(message, witness_set); @@ -331,7 +325,7 @@ fn program_should_fail_if_does_not_preserve_total_balance_by_minting() { let program_id = crate::test_methods::minter().id(); let message = - public_transaction::Message::try_new(program_id, vec![account_id], vec![], ()).unwrap(); + public_transaction::Message::new_feeless(program_id, vec![account_id], vec![], ()); let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); let tx = PublicTransaction::new(message, witness_set); @@ -361,9 +355,12 @@ fn program_should_fail_if_does_not_preserve_total_balance_by_burning() { let balance_to_burn: u128 = 1; assert!(state.get_account_by_id(account_id).balance > balance_to_burn); - let message = - public_transaction::Message::try_new(program_id, vec![account_id], vec![], balance_to_burn) - .unwrap(); + let message = public_transaction::Message::new_feeless( + program_id, + vec![account_id], + vec![], + balance_to_burn, + ); let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); let tx = PublicTransaction::new(message, witness_set); let result = state.transition_from_public_transaction(&tx, 2, 0); diff --git a/lee/state_machine/src/state/tests/validity_window.rs b/lee/state_machine/src/state/tests/validity_window.rs index 7953c671a..6dd6c3c5f 100644 --- a/lee/state_machine/src/state/tests/validity_window.rs +++ b/lee/state_machine/src/state/tests/validity_window.rs @@ -31,8 +31,7 @@ fn validity_window_works_in_public_transactions( TimestampValidityWindow::new_unbounded(), ); let message = - public_transaction::Message::try_new(program_id, account_ids, nonces, instruction) - .unwrap(); + public_transaction::Message::new_feeless(program_id, account_ids, nonces, instruction); let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); PublicTransaction::new(message, witness_set) }; @@ -82,8 +81,7 @@ fn timestamp_validity_window_works_in_public_transactions( timestamp_validity_window, ); let message = - public_transaction::Message::try_new(program_id, account_ids, nonces, instruction) - .unwrap(); + public_transaction::Message::new_feeless(program_id, account_ids, nonces, instruction); let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); PublicTransaction::new(message, witness_set) }; diff --git a/lee/state_machine/src/validated_state_diff/mod.rs b/lee/state_machine/src/validated_state_diff/mod.rs index 195040a64..4bc97d2fc 100644 --- a/lee/state_machine/src/validated_state_diff/mod.rs +++ b/lee/state_machine/src/validated_state_diff/mod.rs @@ -499,6 +499,14 @@ impl ValidatedStateDiff { tx: &ProgramDeploymentTransaction, state: &V03State, ) -> Result { + // Re-verified here, not only at ingest: a deployment can also arrive inside a peer block + // or be replayed from storage, and the fee witness it carries must be an authorization + // fact on every path that applies it. + ensure!( + tx.witness_set().is_valid_for(tx.message()), + LeeError::InvalidInput("Invalid signature for given message and public key".into()) + ); + // TODO: remove clone let program = Program::new(tx.message.bytecode.clone().into())?; if state.programs().contains_key(&program.id()) { diff --git a/lee/state_machine/src/validated_state_diff/tests.rs b/lee/state_machine/src/validated_state_diff/tests.rs index 8462cfcf9..3ebd8ad72 100644 --- a/lee/state_machine/src/validated_state_diff/tests.rs +++ b/lee/state_machine/src/validated_state_diff/tests.rs @@ -48,7 +48,7 @@ fn public_diff_reflects_a_successful_transfer() { )); let program_id = crate::test_methods::simple_balance_transfer().id(); let message = - Message::try_new(program_id, vec![from, to], vec![Nonce(0), Nonce(0)], 5_u128).unwrap(); + Message::new_feeless(program_id, vec![from, to], vec![Nonce(0), Nonce(0)], 5_u128); let witness_set = WitnessSet::for_message(&message, &[&from_key, &to_key]); let tx = crate::PublicTransaction::new(message, witness_set); @@ -449,13 +449,12 @@ fn malicious_programs_cannot_drain_victim_without_signature() { victim_balance, ); - let message = Message::try_new( + let message = Message::new_feeless( crate::test_methods::malicious_injector().id(), vec![attacker_id], vec![Nonce(0)], instruction, - ) - .unwrap(); + ); let witness_set = WitnessSet::for_message(&message, &[&attacker_key]); let tx = crate::PublicTransaction::new(message, witness_set); @@ -558,14 +557,13 @@ fn chained_transfer_transaction( num_chain_calls, None, ); - let message = Message::try_new( + let message = Message::new_feeless( crate::test_methods::chain_caller().id(), // The `chain_caller` program permutes the account order in the chained call. vec![to, from], vec![Nonce(0)], instruction, - ) - .unwrap(); + ); let witness_set = WitnessSet::for_message(&message, &[from_key]); crate::PublicTransaction::new(message, witness_set) } @@ -612,3 +610,122 @@ fn chained_calls_share_a_single_cycle_budget() { "a chain outgrowing its budget must halt with OutOfGas", ); } + +/// A program-deployment transaction whose bytecode deploys cleanly, so the only thing under test +/// below is its witness set. +fn deployment_tx( + fees: crate::FeeFields, + witness: impl FnOnce(&crate::program_deployment_transaction::Message) -> WitnessSet, +) -> crate::ProgramDeploymentTransaction { + let message = crate::program_deployment_transaction::Message::new( + crate::test_methods::noop().elf().to_owned(), + fees, + ); + let witness_set = witness(&message); + crate::ProgramDeploymentTransaction::new(message, witness_set) +} + +/// The apply path — not just ingest — must reject a deployment whose witness signature does not +/// verify. Deployments also arrive inside peer blocks and are replayed from storage, neither of +/// which goes through `transaction_stateless_check`, so T8 could otherwise read a forged fee +/// witness as an authorization fact. +#[test] +fn deployment_with_an_invalid_witness_is_rejected_on_the_apply_path() { + let deployer = PrivateKey::try_new([1; 32]).unwrap(); + let payer = AccountId::from(&PublicKey::new_from_private_key(&deployer)); + let fees = crate::FeeFields::new(payer, 60_000, 0, 1_000_000); + + // Control: the same transaction with a valid witness deploys. + let valid = deployment_tx(fees, |message| { + WitnessSet::for_message(message, &[&deployer]) + }); + let mut state = V03State::new(); + state + .transition_from_program_deployment_transaction(&valid) + .expect("a correctly witnessed deployment must apply"); + + // Same message, same signer, but the signature bytes are garbage. + let tampered = deployment_tx(fees, |message| { + let mut witness_set = WitnessSet::for_message(message, &[&deployer]); + witness_set.signatures_and_public_keys[0].0 = crate::Signature::new_for_tests([1; 64]); + witness_set + }); + assert!( + matches!( + ValidatedStateDiff::from_program_deployment_transaction(&tampered, &V03State::new()), + Err(LeeError::InvalidInput(_)) + ), + "a deployment with an invalid witness signature must not produce a diff" + ); + let mut fresh_state = V03State::new(); + assert!( + matches!( + fresh_state.transition_from_program_deployment_transaction(&tampered), + Err(LeeError::InvalidInput(_)) + ), + "and must not apply to state" + ); + assert!( + fresh_state.programs().is_empty(), + "a rejected deployment must leave no program behind" + ); +} + +/// A sponsored deployment whose fee witness does not verify is rejected on the apply path too — +/// the fee witness is checked, not merely carried. +#[test] +fn deployment_with_an_invalid_fee_witness_is_rejected_on_the_apply_path() { + let deployer = PrivateKey::try_new([1; 32]).unwrap(); + let sponsor = PrivateKey::try_new([3; 32]).unwrap(); + let forger = PrivateKey::try_new([4; 32]).unwrap(); + let sponsor_id = AccountId::from(&PublicKey::new_from_private_key(&sponsor)); + let fees = crate::FeeFields::new(sponsor_id, 60_000, 0, 1_000_000); + + // Control: a genuine sponsor signature deploys, and the payer is fee-authorized. + let sponsored = deployment_tx(fees, |message| { + WitnessSet::for_message(message, &[&deployer]).with_fee_signer(message, &sponsor) + }); + assert!(crate::is_fee_authorized( + sponsored.message(), + sponsored.witness_set() + )); + let mut state = V03State::new(); + state + .transition_from_program_deployment_transaction(&sponsored) + .expect("a correctly sponsored deployment must apply"); + + // The fee witness claims the sponsor's public key but was signed by somebody else. + let forged = deployment_tx(fees, |message| { + let mut witness_set = WitnessSet::for_message(message, &[&deployer]); + witness_set.fee_witness = Some(( + crate::Signature::new(&forger, &message.hash()), + PublicKey::new_from_private_key(&sponsor), + )); + witness_set + }); + let mut fresh_state = V03State::new(); + assert!( + matches!( + fresh_state.transition_from_program_deployment_transaction(&forged), + Err(LeeError::InvalidInput(_)) + ), + "a forged fee witness must be rejected on the apply path" + ); + assert!(fresh_state.programs().is_empty()); +} + +/// The existing unsigned-deployment shape stays valid: an empty witness set authorizes nobody but +/// is not itself a signature failure, so today's deployment flows keep working. +#[test] +fn deployment_without_a_witness_still_applies_but_authorizes_nobody() { + let tx = deployment_tx(crate::FeeFields::ZERO, |_| { + WitnessSet::from_raw_parts(vec![]) + }); + assert!(!crate::is_fee_authorized(tx.message(), tx.witness_set())); + + let mut state = V03State::new(); + state + .transition_from_program_deployment_transaction(&tx) + .expect("an unwitnessed deployment must still apply"); + assert_eq!(state.programs().len(), 1); +} diff --git a/lez/chain_state/src/apply.rs b/lez/chain_state/src/apply.rs index 05134b88e..4bd673345 100644 --- a/lez/chain_state/src/apply.rs +++ b/lez/chain_state/src/apply.rs @@ -76,9 +76,13 @@ pub fn apply_block( Ok(()) } -/// Checks that `block` is the valid continuation of `tip`: hash integrity, -/// then block-id continuity, then `prev_block_hash` linkage. A `None` tip -/// (cold state) expects the genesis block. +/// Checks that `block` is the valid continuation of `tip`. +/// +/// In order: hash integrity, the producer's signature over it, block-id +/// continuity, then `prev_block_hash` linkage. A `None` tip (cold state) +/// expects the genesis block, which is validated exactly like any other block — +/// it is produced by a sequencer with its own signing key, so it carries a real +/// `producer` and a real signature. pub fn validate_against_tip(tip: Option<&Tip>, block: &Block) -> Result<(), BlockIngestError> { let computed = block.recompute_hash(); if computed != block.header.hash { @@ -88,6 +92,22 @@ pub fn validate_against_tip(tip: Option<&Tip>, block: &Block) -> Result<(), Bloc }); } + // The producer is inside the hashed content, so this ties the block to the + // key it names: the hash cannot be re-pointed at another producer without + // breaking the check above. Verified against the hash just recomputed + // rather than through `is_signed_by`, which would hash the body a second + // time. Nothing here pins a single key — each block is checked against the + // producer it declares, so blocks from other sequencers validate too. + if !block + .header + .signature + .is_valid_for(&computed.0, &block.header.producer) + { + return Err(BlockIngestError::InvalidProducerSignature { + producer: block.header.producer.clone(), + }); + } + match tip { None => { if block.header.block_id != GENESIS_BLOCK_ID { @@ -177,7 +197,8 @@ mod tests { block::HashableBlockData, test_utils::{ create_transaction_native_token_transfer, produce_dummy_block, - produce_dummy_empty_transaction, sequencer_sign_key_for_testing, + produce_dummy_empty_transaction, sequencer_producer_key_for_testing, + sequencer_sign_key_for_testing, }, }; use testnet_initial_state::{initial_pub_accounts_private_keys, initial_state}; @@ -188,6 +209,44 @@ mod tests { Tip::from(block) } + /// Genesis-shaped block claiming `producer` and signed with `signing_key`, + /// which the callers below deliberately let disagree. + fn genesis_claiming(producer: &lee::PublicKey, signing_key: &lee::PrivateKey) -> Block { + HashableBlockData { + block_id: GENESIS_BLOCK_ID, + prev_block_hash: HashType([0_u8; 32]), + timestamp: 100, + producer: producer.clone(), + transactions: vec![LeeTransaction::Public(clock_invocation(100))], + } + .into_pending_block(signing_key) + } + + #[test] + fn signature_by_a_key_other_than_the_producer_is_rejected() { + let mut state = initial_state(); + let impostor = lee::PrivateKey::try_new([11_u8; 32]).expect("valid key"); + // Names the honest sequencer as producer, but is signed by someone else. + let block = genesis_claiming(&sequencer_producer_key_for_testing(), &impostor); + + let err = apply_block(None, &block, &mut state).expect_err("should reject"); + assert!(matches!( + err, + BlockIngestError::InvalidProducerSignature { .. } + )); + } + + #[test] + fn block_from_another_producer_applies() { + let mut state = initial_state(); + // Multi-sequencer: the check pins nothing, it only demands that the + // signature is by whichever producer the header names. + let other = lee::PrivateKey::try_new([11_u8; 32]).expect("valid key"); + let block = genesis_claiming(&lee::PublicKey::new_from_private_key(&other), &other); + + apply_block(None, &block, &mut state).expect("a well-signed foreign block applies"); + } + #[test] fn genesis_applies_on_empty_tip() { let mut state = initial_state(); @@ -259,6 +318,7 @@ mod tests { block_id: 1, prev_block_hash: HashType([0_u8; 32]), timestamp: 0, + producer: sequencer_producer_key_for_testing(), transactions: vec![], } .into_pending_block(&sequencer_sign_key_for_testing()); @@ -274,6 +334,7 @@ mod tests { block_id: 1, prev_block_hash: HashType([0_u8; 32]), timestamp: 50, + producer: sequencer_producer_key_for_testing(), transactions: vec![produce_dummy_empty_transaction()], } .into_pending_block(&sequencer_sign_key_for_testing()); diff --git a/lez/chain_state/src/consistency.rs b/lez/chain_state/src/consistency.rs index 5c87fcadf..8ffbe8c5e 100644 --- a/lez/chain_state/src/consistency.rs +++ b/lez/chain_state/src/consistency.rs @@ -323,13 +323,15 @@ mod tests { use super::*; fn test_block(block_id: BlockId, timestamp: u64) -> Block { + let signing_key = lee::PrivateKey::try_new([7; 32]).expect("valid key"); HashableBlockData { block_id, prev_block_hash: HashType([0; 32]), timestamp, + producer: lee::PublicKey::new_from_private_key(&signing_key), transactions: vec![], } - .into_pending_block(&lee::PrivateKey::try_new([7; 32]).expect("valid key")) + .into_pending_block(&signing_key) } fn block_msg(block: &Block) -> ZoneMessage { diff --git a/lez/chain_state/src/ingest_error.rs b/lez/chain_state/src/ingest_error.rs index d259b5f79..ef78dcdd3 100644 --- a/lez/chain_state/src/ingest_error.rs +++ b/lez/chain_state/src/ingest_error.rs @@ -22,6 +22,8 @@ pub enum BlockIngestError { computed: HashType, header: HashType, }, + #[error("Block signature is not by the producer {producer} named in the header")] + InvalidProducerSignature { producer: lee::PublicKey }, #[error("Block has no transactions")] EmptyBlock, #[error("Last transaction must be the public clock invocation for the block timestamp")] @@ -77,4 +79,22 @@ mod tests { } )); } + + #[test] + fn invalid_producer_signature_round_trips() { + // Stall reasons are persisted, so the producer key has to survive a + // storage round trip. + let producer = lee::PublicKey::new_from_private_key( + &lee::PrivateKey::try_new([7_u8; 32]).expect("valid key"), + ); + let err = BlockIngestError::InvalidProducerSignature { + producer: producer.clone(), + }; + let value = serde_json::to_value(&err).expect("serialize"); + let back: BlockIngestError = serde_json::from_value(value).expect("deserialize"); + let BlockIngestError::InvalidProducerSignature { producer: back } = back else { + panic!("wrong variant"); + }; + assert_eq!(back, producer); + } } diff --git a/lez/common/src/block.rs b/lez/common/src/block.rs index 53a2f0337..7348abee7 100644 --- a/lez/common/src/block.rs +++ b/lez/common/src/block.rs @@ -42,6 +42,9 @@ pub struct BlockHeader { pub prev_block_hash: BlockHash, pub hash: BlockHash, pub timestamp: Timestamp, + /// Public key of the sequencer that produced the block. Part of the hashed + /// content, so `signature` covers it and it cannot be swapped in transit. + pub producer: lee::PublicKey, pub signature: lee::Signature, } @@ -73,6 +76,7 @@ impl Block { block_id: self.header.block_id, prev_block_hash: self.header.prev_block_hash, timestamp: self.header.timestamp, + producer: self.header.producer.clone(), transactions: self.body.transactions.clone(), } .compute_hash() @@ -84,8 +88,9 @@ impl Block { /// sequencer is rejected even if it reached the channel. #[must_use] pub fn is_signed_by(&self, expected_pubkey: &lee::PublicKey) -> bool { - let hash = HashableBlockData::from(self.clone()).compute_hash(); - self.header.signature.is_valid_for(&hash.0, expected_pubkey) + self.header + .signature + .is_valid_for(&self.recompute_hash().0, expected_pubkey) } } @@ -106,6 +111,11 @@ pub struct HashableBlockData { pub block_id: BlockId, pub prev_block_hash: BlockHash, pub timestamp: Timestamp, + /// The producing sequencer's block-signing public key. Hashed with the rest + /// of the contents, so [`Block::is_signed_by`] only accepts a signature by + /// this exact key: a relayer cannot re-point the header at another producer + /// without breaking the hash. + pub producer: lee::PublicKey, pub transactions: Vec, } @@ -128,6 +138,10 @@ impl HashableBlockData { OwnHasher::hash(&bytes) } + /// Hashes the contents and signs the hash with `signing_key`. + /// + /// `signing_key` must be the private key behind `self.producer`; the shared + /// apply path rejects a block whose signature is not by `header.producer`. #[must_use] pub fn into_pending_block(self, signing_key: &lee::PrivateKey) -> Block { let hash = self.compute_hash(); @@ -138,6 +152,7 @@ impl HashableBlockData { prev_block_hash: self.prev_block_hash, hash, timestamp: self.timestamp, + producer: self.producer, signature, }, body: BlockBody { @@ -154,6 +169,7 @@ impl From for HashableBlockData { block_id: value.header.block_id, prev_block_hash: value.header.prev_block_hash, timestamp: value.header.timestamp, + producer: value.header.producer, transactions: value.body.transactions, } } @@ -173,32 +189,50 @@ mod tests { assert_eq!(hashable, block_from_bytes); } - #[test] - fn recompute_hash_matches_header_for_well_formed_block() { - let key = lee::PrivateKey::try_new([7_u8; 32]).expect("valid key"); - let block = HashableBlockData { + fn block_signed_by(key: &lee::PrivateKey) -> crate::block::Block { + HashableBlockData { block_id: 5, prev_block_hash: HashType([9_u8; 32]), timestamp: 42, + producer: lee::PublicKey::new_from_private_key(key), transactions: vec![test_utils::produce_dummy_empty_transaction()], } - .into_pending_block(&key); + .into_pending_block(key) + } + + #[test] + fn recompute_hash_matches_header_for_well_formed_block() { + let key = lee::PrivateKey::try_new([7_u8; 32]).expect("valid key"); + let block = block_signed_by(&key); assert_eq!(block.recompute_hash(), block.header.hash); } #[test] fn recompute_hash_detects_tampering() { let key = lee::PrivateKey::try_new([7_u8; 32]).expect("valid key"); - let block = HashableBlockData { - block_id: 5, - prev_block_hash: HashType([9_u8; 32]), - timestamp: 42, - transactions: vec![test_utils::produce_dummy_empty_transaction()], - } - .into_pending_block(&key); - - let mut tampered = block; + let mut tampered = block_signed_by(&key); tampered.header.timestamp = 99; // header changed; stale hash no longer matches assert_ne!(tampered.recompute_hash(), tampered.header.hash); } + + #[test] + fn block_is_signed_by_its_producer() { + let key = lee::PrivateKey::try_new([7_u8; 32]).expect("valid key"); + let block = block_signed_by(&key); + assert!(block.is_signed_by(&block.header.producer)); + } + + #[test] + fn recompute_hash_detects_producer_tampering() { + let key = lee::PrivateKey::try_new([7_u8; 32]).expect("valid key"); + let other = lee::PrivateKey::try_new([8_u8; 32]).expect("valid key"); + let mut tampered = block_signed_by(&key); + + // Re-pointing the header at another producer is what a relayer would do + // to have the payout credited elsewhere; the producer is hashed, so the + // stored hash (and with it the signature) no longer matches. + tampered.header.producer = lee::PublicKey::new_from_private_key(&other); + assert_ne!(tampered.recompute_hash(), tampered.header.hash); + assert!(!tampered.is_signed_by(&tampered.header.producer)); + } } diff --git a/lez/common/src/test_utils.rs b/lez/common/src/test_utils.rs index 4a9ab9929..5edd86989 100644 --- a/lez/common/src/test_utils.rs +++ b/lez/common/src/test_utils.rs @@ -21,6 +21,13 @@ pub fn sequencer_sign_key_for_testing() -> lee::PrivateKey { lee::PrivateKey::try_new([37; 32]).unwrap() } +/// The `producer` a block signed with [`sequencer_sign_key_for_testing`] must +/// carry. +#[must_use] +pub fn sequencer_producer_key_for_testing() -> lee::PublicKey { + lee::PublicKey::new_from_private_key(&sequencer_sign_key_for_testing()) +} + /// A syntactically valid `Public` transaction. Its contents are irrelevant to the /// bridge guard, which only branches on the transaction *variant* and the diff. #[cfg(test)] @@ -71,6 +78,7 @@ pub fn produce_dummy_block( block_id: id, prev_block_hash: prev_hash.unwrap_or_default(), timestamp: id.saturating_mul(100), + producer: sequencer_producer_key_for_testing(), transactions, }; @@ -87,6 +95,7 @@ pub fn produce_dummy_empty_transaction() -> LeeTransaction { account_ids, nonces, authenticated_transfer_core::Instruction::Initialize, + lee::FeeFields::ZERO, ) .unwrap(); let private_key = lee::PrivateKey::try_new([1; 32]).unwrap(); @@ -115,6 +124,7 @@ pub fn create_transaction_native_token_transfer( authenticated_transfer_core::Instruction::Transfer { amount: balance_to_move, }, + lee::FeeFields::ZERO, ) .unwrap(); let witness_set = lee::public_transaction::WitnessSet::for_message(&message, &[signing_key]); diff --git a/lez/common/src/transaction.rs b/lez/common/src/transaction.rs index 248d32137..0a34e88d5 100644 --- a/lez/common/src/transaction.rs +++ b/lez/common/src/transaction.rs @@ -72,7 +72,13 @@ impl LeeTransaction { Err(TransactionMalformationError::InvalidSignature) } } - Self::ProgramDeployment(tx) => Ok(Self::ProgramDeployment(tx)), + Self::ProgramDeployment(tx) => { + if tx.witness_set().is_valid_for(tx.message()) { + Ok(Self::ProgramDeployment(tx)) + } else { + Err(TransactionMalformationError::InvalidSignature) + } + } } } @@ -248,6 +254,7 @@ pub fn clock_invocation(timestamp: clock_core::Instruction) -> lee::PublicTransa clock_core::CLOCK_PROGRAM_ACCOUNT_IDS.to_vec(), vec![], timestamp, + lee::FeeFields::ZERO, ) .expect("Clock invocation message should always be constructable"); lee::PublicTransaction::new( @@ -279,11 +286,29 @@ mod tests { use lee::{Account, AccountId, PrivateKey, PublicKey, V03State}; use lee_core::account::Nonce; - use super::validate_doesnt_modify_account; + use super::{clock_invocation, validate_doesnt_modify_account}; use crate::test_utils::{ any_public_transaction, create_transaction_native_token_transfer, state_and_diff, }; + /// The clock transaction is canonical and unsigned, and `apply_block_to_state` + /// checks it by equality — so the build side and the check side must agree + /// byte for byte, fee fields included. It is a system transaction, hence + /// zeroed fees and the system payer. + #[test] + fn clock_invocation_is_canonical_and_carries_zeroed_fees() { + let tx = clock_invocation(1234); + let rebuilt = clock_invocation(1234); + assert_eq!(tx, rebuilt); + assert_eq!(tx.hash(), rebuilt.hash()); + assert_ne!(tx, clock_invocation(1235)); + + assert_eq!(tx.message().fees(), lee::FeeFields::ZERO); + assert_eq!(tx.message().payer, lee::SYSTEM_PAYER); + assert!(tx.witness_set().signatures_and_public_keys().is_empty()); + assert!(tx.witness_set().fee_witness().is_none()); + } + #[test] fn bridge_guard_allows_balance_only_increase() { // A diff that *only* increases the bridge balance (the legitimate deposit shape) diff --git a/lez/cross_zone/src/lib.rs b/lez/cross_zone/src/lib.rs index 38f35d24c..3c2785c8f 100644 --- a/lez/cross_zone/src/lib.rs +++ b/lez/cross_zone/src/lib.rs @@ -102,6 +102,7 @@ fn build_inbox_dispatch_tx( account_ids, vec![], Instruction::Dispatch(msg.clone()), + lee::FeeFields::ZERO, ) .expect("inbox dispatch instruction must serialize"); @@ -209,9 +210,14 @@ fn genesis_public_tx( account_ids: Vec, instruction: I, ) -> lee::PublicTransaction { - let message = - lee::public_transaction::Message::try_new(program_id, account_ids, vec![], instruction) - .expect("genesis instruction must serialize"); + let message = lee::public_transaction::Message::try_new( + program_id, + account_ids, + vec![], + instruction, + lee::FeeFields::ZERO, + ) + .expect("genesis instruction must serialize"); lee::PublicTransaction::new( message, lee::public_transaction::WitnessSet::from_raw_parts(vec![]), diff --git a/lez/explorer_service/src/components/block_preview.rs b/lez/explorer_service/src/components/block_preview.rs index 0d3b9e144..47923f324 100644 --- a/lez/explorer_service/src/components/block_preview.rs +++ b/lez/explorer_service/src/components/block_preview.rs @@ -23,6 +23,7 @@ pub fn BlockPreview(block: Block) -> impl IntoView { prev_block_hash, hash, timestamp, + producer: _, signature: _, }, body: BlockBody { transactions }, diff --git a/lez/explorer_service/src/components/transaction_details.rs b/lez/explorer_service/src/components/transaction_details.rs index 01e5bc485..00713f04d 100644 --- a/lez/explorer_service/src/components/transaction_details.rs +++ b/lez/explorer_service/src/components/transaction_details.rs @@ -14,15 +14,21 @@ pub fn PublicTxDetails(tx: PublicTransaction) -> impl IntoView { message, witness_set, } = tx; + // TODO(T8): surface the fee fields once they carry real values. let PublicMessage { program_id, account_ids, nonces, instruction_data, + payer: _, + gas_limit: _, + tip: _, + max_fee: _, } = message; let WitnessSet { signatures_and_public_keys, proof, + fee_witness: _, } = witness_set; let program_id_str = program_id.to_string(); @@ -83,6 +89,7 @@ pub fn PrivacyPreservingTxDetails(tx: PrivacyPreservingTransaction) -> impl Into let WitnessSet { signatures_and_public_keys: _, proof, + fee_witness: _, } = witness_set; let proof_len = proof.map_or(0, |p| p.0.len()); @@ -123,8 +130,19 @@ pub fn PrivacyPreservingTxDetails(tx: PrivacyPreservingTransaction) -> impl Into /// Program deployment transaction details component #[component] pub fn ProgramDeploymentTxDetails(tx: ProgramDeploymentTransaction) -> impl IntoView { - let ProgramDeploymentTransaction { hash: _, message } = tx; - let ProgramDeploymentMessage { bytecode } = message; + let ProgramDeploymentTransaction { + hash: _, + message, + witness_set: _, + } = tx; + // TODO(T8): surface the fee fields once they carry real values. + let ProgramDeploymentMessage { + bytecode, + payer: _, + gas_limit: _, + tip: _, + max_fee: _, + } = message; let bytecode_len = bytecode.len(); view! { diff --git a/lez/explorer_service/src/components/transaction_preview.rs b/lez/explorer_service/src/components/transaction_preview.rs index 20c26235a..d083ededc 100644 --- a/lez/explorer_service/src/components/transaction_preview.rs +++ b/lez/explorer_service/src/components/transaction_preview.rs @@ -45,7 +45,11 @@ pub fn TransactionPreview(transaction: Transaction) -> impl IntoView { ) } Transaction::ProgramDeployment(tx) => { - let indexer_service_protocol::ProgramDeploymentTransaction { hash: _, message } = tx; + let indexer_service_protocol::ProgramDeploymentTransaction { + hash: _, + message, + witness_set: _, + } = tx; format!("{} bytes", message.bytecode.len()) } }; diff --git a/lez/explorer_service/src/pages/block_page.rs b/lez/explorer_service/src/pages/block_page.rs index 5df3b4dc8..aacb35658 100644 --- a/lez/explorer_service/src/pages/block_page.rs +++ b/lez/explorer_service/src/pages/block_page.rs @@ -58,6 +58,7 @@ pub fn BlockPage() -> impl IntoView { prev_block_hash, hash, timestamp, + producer: _, signature, }, body: BlockBody { diff --git a/lez/fee_core/src/validity.rs b/lez/fee_core/src/validity.rs index 5bb2fc44b..14f6d7b04 100644 --- a/lez/fee_core/src/validity.rs +++ b/lez/fee_core/src/validity.rs @@ -12,22 +12,22 @@ use crate::{ }; /// D2 seam: the fee treatment of program deployments. -// TBA(Q2) +// TBA(Q2): answered — deployments are folded into the public fee model. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DeploymentFeePolicy { - /// Deployments pay no execution/storage base fee but their storage - /// contribution is still capped. + /// Deployments are priced exactly like public transactions: storage gas + /// over the full serialized transaction (ELF included), execution gas over + /// their metered cycles. /// - /// Enforcement (recognizing a deployment and applying the cap) is the - /// block-level caller's job (T8); `fee_core` only exposes the knob. - FeeExemptStorageCapped, + /// Charging (recognizing a deployment and assessing it) is the block-level + /// caller's job (T8); `fee_core` only exposes the knob. + PricedAsPublic, } -/// The default deployment fee policy. +/// The deployment fee policy (tokenomics ruling Q2). #[must_use] -// TBA(Q2) pub const fn deployment_policy() -> DeploymentFeePolicy { - DeploymentFeePolicy::FeeExemptStorageCapped + DeploymentFeePolicy::PricedAsPublic } /// Static per-transaction fee-validity (SPECS §Fee-validity). @@ -136,17 +136,27 @@ pub fn accumulate_gas_used(running_total: u64, amount: u64, cap: u64) -> Result< Ok(total) } -/// D1 seam: authorizes a transaction's payer against its signer set. +/// D1 seam: authorizes a transaction's designated payer. /// -/// Default rule: the payer must be a member of the signers. +/// Q1 (answered): the payer is any account whose fee authorization accompanies +/// the transaction — an explicit designation plus a signature over the fee +/// fields and the exact transaction they cover. The payer MAY be one of the +/// transaction's signers and MAY be a third party outside the witness set +/// (sponsored transactions), and is never inferred from the witness set. +/// +/// `fee_core` is pure arithmetic and cannot verify signatures, so the caller +/// supplies `authorized`: the account ids whose fee authorization verified at +/// the wire layer (`lee::fee_authorized_account_ids`). All this seam decides is +/// membership. /// /// # Errors /// /// Returns `InvalidBlock(UnauthorizedPayer)` if `payer` is not one of -/// `signers`. -// TBA(Q1) -pub fn authorize_payer(payer: PayerId, signers: &[PayerId]) -> Result<(), FeeError> { - if signers.contains(&payer) { +/// `authorized`. +// TBA(Q1-program-auth): the ruling also allows a program authorization; it +// enters through `authorized`, not through this function. +pub fn authorize_payer(payer: PayerId, authorized: &[PayerId]) -> Result<(), FeeError> { + if authorized.contains(&payer) { Ok(()) } else { Err(FeeError::InvalidBlock(InvalidBlockError::UnauthorizedPayer)) @@ -154,26 +164,29 @@ pub fn authorize_payer(payer: PayerId, signers: &[PayerId]) -> Result<(), FeeErr } /// D3 seam: authorizes a private transaction's payer against the tx's -/// public-signer set. +/// public fee-authorized set. /// /// A private transaction still names public signers for authorization; the /// proof itself carries no public fee fields. Default rule: the payer must -/// be one of the public signers, and an empty public-signer set is +/// be one of the publicly authorized accounts, and an empty set is /// rejected, so fully-shielded transactions cannot pay fees until Q3 is /// decided. /// /// # Errors /// -/// Returns `InvalidBlock(EmptyPublicSignerSet)` if `public_signers` is +/// Returns `InvalidBlock(EmptyPublicSignerSet)` if `public_authorized` is /// empty, or `InvalidBlock(UnauthorizedPayer)` if `payer` is not among them. // TBA(Q3) -pub fn authorize_private_payer(payer: PayerId, public_signers: &[PayerId]) -> Result<(), FeeError> { - if public_signers.is_empty() { +pub fn authorize_private_payer( + payer: PayerId, + public_authorized: &[PayerId], +) -> Result<(), FeeError> { + if public_authorized.is_empty() { return Err(FeeError::InvalidBlock( InvalidBlockError::EmptyPublicSignerSet, )); } - authorize_payer(payer, public_signers) + authorize_payer(payer, public_authorized) } #[cfg(test)] @@ -333,13 +346,27 @@ mod tests { )); } + /// Q1: membership in the caller-supplied authorized set, whether the payer + /// got there as a signer or as a sponsor outside the witness set. #[test] - fn authorize_payer_requires_membership() { + fn authorize_payer_requires_membership_in_the_authorized_set() { assert!(authorize_payer(PAYER, &[PAYER, OTHER]).is_ok()); + // Sponsored: the payer is authorized without being a transaction signer. + assert!(authorize_payer(PAYER, &[OTHER, PAYER]).is_ok()); assert_eq!( authorize_payer(PAYER, &[OTHER]), Err(FeeError::InvalidBlock(InvalidBlockError::UnauthorizedPayer)) ); + assert_eq!( + authorize_payer(PAYER, &[]), + Err(FeeError::InvalidBlock(InvalidBlockError::UnauthorizedPayer)) + ); + } + + /// Q2: deployments are priced as public transactions, not fee-exempt. + #[test] + fn deployment_policy_prices_deployments_as_public() { + assert_eq!(deployment_policy(), DeploymentFeePolicy::PricedAsPublic); } #[test] diff --git a/lez/indexer/core/src/block_store.rs b/lez/indexer/core/src/block_store.rs index ce6cfc615..9fce8c700 100644 --- a/lez/indexer/core/src/block_store.rs +++ b/lez/indexer/core/src/block_store.rs @@ -421,6 +421,7 @@ mod accept_tests { block_id, prev_block_hash: prev, timestamp: 0, + producer: lee::PublicKey::new_from_private_key(&signing_key()), transactions: vec![], } .into_pending_block(&signing_key()) @@ -449,6 +450,33 @@ mod accept_tests { assert_eq!(stall.orphans_since, 0); } + #[tokio::test] + async fn signature_by_a_key_other_than_the_producer_parks() { + let dir = tempfile::tempdir().expect("tempdir"); + let store = IndexerStore::open_db(dir.path(), Vec::new()).expect("open store"); + + // Correctly chained and correctly hashed, but the header names a + // producer that did not sign it. + let impostor = lee::PrivateKey::try_new([11_u8; 32]).expect("valid key"); + let block = HashableBlockData { + block_id: 1, + prev_block_hash: HashType([0_u8; 32]), + timestamp: 0, + producer: lee::PublicKey::new_from_private_key(&signing_key()), + transactions: vec![], + } + .into_pending_block(&impostor); + + let outcome = store + .accept_block(&block, Slot::from(0)) + .await + .expect("accept"); + assert!(matches!( + outcome, + AcceptOutcome::Parked(BlockIngestError::InvalidProducerSignature { .. }) + )); + } + #[tokio::test] async fn hash_mismatch_parks() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/lez/indexer/core/src/cross_zone_verifier.rs b/lez/indexer/core/src/cross_zone_verifier.rs index 9dcfe2eb5..75081cda1 100644 --- a/lez/indexer/core/src/cross_zone_verifier.rs +++ b/lez/indexer/core/src/cross_zone_verifier.rs @@ -412,9 +412,12 @@ impl CrossZoneVerifier { .await?; // Equivocation defense: the source block must be signed by the peer's - // pinned block-signing key, not merely inscribed on the channel. + // pinned block-signing key, not merely inscribed on the channel. The + // header now names its producer, so the pinned key is checked against + // that too — the signature verification below is what proves the claim, + // and the comparison keeps the externally-pinned key authoritative. if let Some(expected) = self.peer_pubkeys.get(&msg.src_zone) - && !peer_block.is_signed_by(expected) + && (&peer_block.header.producer != expected || !peer_block.is_signed_by(expected)) { return Err(CrossZoneVerifyError::Forged(anyhow!( "forged cross-zone dispatch: peer zone {} block {} is not signed by the pinned block-signing key", @@ -552,7 +555,7 @@ fn accept_peer_block( } if let Some(expected) = expected_pubkey - && !block.is_signed_by(expected) + && (&block.header.producer != expected || !block.is_signed_by(expected)) { warn!( "Peer reader dropping block {} from {}: not signed by the pinned block-signing key", @@ -739,8 +742,14 @@ mod tests { payload: payload.to_vec(), ordinal: 0, }; - let message = Message::try_new(programs::ping_sender().id(), vec![], vec![], send) - .expect("emission serializes"); + let message = Message::try_new( + programs::ping_sender().id(), + vec![], + vec![], + send, + lee::FeeFields::ZERO, + ) + .expect("emission serializes"); LeeTransaction::Public(PublicTransaction::new( message, WitnessSet::from_raw_parts(vec![]), diff --git a/lez/indexer/core/src/lib.rs b/lez/indexer/core/src/lib.rs index ccf8dbe7b..6ef76c370 100644 --- a/lez/indexer/core/src/lib.rs +++ b/lez/indexer/core/src/lib.rs @@ -542,13 +542,15 @@ mod tests { } fn test_block(block_id: u64, timestamp: u64) -> Block { + let signing_key = lee::PrivateKey::try_new([7; 32]).expect("valid key"); HashableBlockData { block_id, prev_block_hash: HashType([0; 32]), timestamp, + producer: lee::PublicKey::new_from_private_key(&signing_key), transactions: vec![], } - .into_pending_block(&lee::PrivateKey::try_new([7; 32]).expect("valid key")) + .into_pending_block(&signing_key) } #[tokio::test] diff --git a/lez/indexer/ffi/indexer_ffi.h b/lez/indexer/ffi/indexer_ffi.h index 16e5a17c3..72bd07ed2 100644 --- a/lez/indexer/ffi/indexer_ffi.h +++ b/lez/indexer/ffi/indexer_ffi.h @@ -108,6 +108,8 @@ typedef struct FfiBytes32 FfiHashType; typedef uint64_t FfiTimestamp; +typedef struct FfiBytes32 FfiPublicKey; + /** * 64-byte array type for signatures, etc. */ @@ -122,6 +124,7 @@ typedef struct FfiBlockHeader { FfiHashType prev_block_hash; FfiHashType hash; FfiTimestamp timestamp; + FfiPublicKey producer; FfiSignature signature; } FfiBlockHeader; @@ -174,8 +177,6 @@ typedef struct FfiPublicMessage { FfiInstructionDataList instruction_data; } FfiPublicMessage; -typedef struct FfiBytes32 FfiPublicKey; - typedef struct FfiSignaturePubKeyEntry { FfiSignature signature; FfiPublicKey public_key; diff --git a/lez/indexer/ffi/src/api/types/block.rs b/lez/indexer/ffi/src/api/types/block.rs index b652a7fe8..987bcc91c 100644 --- a/lez/indexer/ffi/src/api/types/block.rs +++ b/lez/indexer/ffi/src/api/types/block.rs @@ -1,7 +1,7 @@ -use indexer_service_protocol::{BedrockStatus, Block, BlockHeader, HashType, Signature}; +use indexer_service_protocol::{BedrockStatus, Block, BlockHeader, HashType, PublicKey, Signature}; use crate::api::types::{ - FfiBlockId, FfiHashType, FfiOption, FfiSignature, FfiTimestamp, FfiVec, + FfiBlockId, FfiHashType, FfiOption, FfiPublicKey, FfiSignature, FfiTimestamp, FfiVec, transaction::free_transaction_vec_value, vectors::FfiBlockBody, }; @@ -41,6 +41,7 @@ pub struct FfiBlockHeader { pub prev_block_hash: FfiHashType, pub hash: FfiHashType, pub timestamp: FfiTimestamp, + pub producer: FfiPublicKey, pub signature: FfiSignature, } @@ -51,6 +52,7 @@ impl From for FfiBlockHeader { prev_block_hash, hash, timestamp, + producer, signature, } = value; @@ -59,6 +61,7 @@ impl From for FfiBlockHeader { prev_block_hash: prev_block_hash.into(), hash: hash.into(), timestamp, + producer: producer.into(), signature: signature.into(), } } @@ -120,6 +123,7 @@ pub unsafe extern "C" fn free_ffi_block(val: FfiBlock) { prev_block_hash: HashType(val.header.prev_block_hash.data), hash: HashType(val.header.hash.data), timestamp: val.header.timestamp, + producer: PublicKey(val.header.producer.data), signature: Signature(val.header.signature.data), }; let ffi_tx_ffi_vec = val.body; diff --git a/lez/indexer/ffi/src/api/types/transaction.rs b/lez/indexer/ffi/src/api/types/transaction.rs index 83f50ede5..075ea63a2 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(), + // TODO(T8): the C ABI does not carry fee fields yet, so they + // come back zeroed here, as `proof` already does below. + payer: AccountId { value: [0; 32] }, + gas_limit: 0, + tip: 0, + max_fee: 0, }, witness_set: WitnessSet { signatures_and_public_keys: { @@ -80,6 +86,7 @@ impl From> for PublicTransaction { .collect() }, proof: None, + fee_witness: None, }, } } @@ -100,6 +107,11 @@ impl From for FfiPublicMessage { account_ids, nonces, instruction_data, + // TODO(T8): surface the fee fields over the C ABI. + payer: _, + gas_limit: _, + tip: _, + max_fee: _, } = value; Self { @@ -213,6 +225,7 @@ impl From> for PrivacyPreservingTransaction { .collect() }, proof: Some(Proof(value.proof.into())), + fee_witness: None, }, } } @@ -351,6 +364,16 @@ impl From> for ProgramDeploymentTransac hash: HashType(value.hash.data), message: ProgramDeploymentMessage { bytecode: value.message.into(), + // TODO(T8): the C ABI does not carry fee fields yet. + payer: AccountId { value: [0; 32] }, + gas_limit: 0, + tip: 0, + max_fee: 0, + }, + witness_set: WitnessSet { + signatures_and_public_keys: vec![], + proof: None, + fee_witness: None, }, } } @@ -358,7 +381,11 @@ impl From> for ProgramDeploymentTransac impl From for FfiProgramDeploymentTransactionBody { fn from(value: ProgramDeploymentTransaction) -> Self { - let ProgramDeploymentTransaction { hash, message } = value; + let ProgramDeploymentTransaction { + hash, + message, + witness_set: _, + } = value; Self { hash: hash.into(), diff --git a/lez/indexer/service/protocol/src/convert.rs b/lez/indexer/service/protocol/src/convert.rs index 79ea0fe75..8455451e2 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), ) } } @@ -382,16 +395,35 @@ impl TryFrom for lee::privacy_preserving_transaction:: impl From for ProgramDeploymentMessage { fn from(value: lee::program_deployment_transaction::Message) -> Self { + let lee::FeeFields { + payer, + gas_limit, + tip, + max_fee, + } = value.fees(); Self { bytecode: value.into_bytecode(), + payer: payer.into(), + gas_limit, + tip, + max_fee, } } } impl From for lee::program_deployment_transaction::Message { fn from(value: ProgramDeploymentMessage) -> Self { - let ProgramDeploymentMessage { bytecode } = value; - Self::new(bytecode) + let ProgramDeploymentMessage { + bytecode, + payer, + gas_limit, + tip, + max_fee, + } = value; + Self::new( + bytecode, + lee::FeeFields::new(payer.into(), gas_limit, tip, max_fee), + ) } } @@ -408,6 +440,9 @@ impl From for WitnessSet { .map(|(sig, pk)| (sig.clone().into(), pk.clone().into())) .collect(), proof: None, + fee_witness: value + .fee_witness() + .map(|(sig, pk)| (sig.clone().into(), pk.clone().into())), } } } @@ -421,6 +456,7 @@ impl From for Witn .map(|(sig, pk)| (sig.into(), pk.into())) .collect(), proof: Some(proof.into()), + fee_witness: None, } } } @@ -432,6 +468,7 @@ impl TryFrom for lee::privacy_preserving_transaction::witness_set::W let WitnessSet { signatures_and_public_keys, proof, + fee_witness: _, } = value; let signatures_and_public_keys = signatures_and_public_keys .into_iter() @@ -447,6 +484,29 @@ impl TryFrom for lee::privacy_preserving_transaction::witness_set::W } } +impl TryFrom for lee::public_transaction::WitnessSet { + type Error = lee::error::LeeError; + + fn try_from(value: WitnessSet) -> Result { + let WitnessSet { + signatures_and_public_keys, + proof: _, + fee_witness, + } = value; + let convert = |(sig, pk): (Signature, PublicKey)| -> Result<_, Self::Error> { + Ok((sig.into(), pk.try_into()?)) + }; + + Ok(Self::from_parts( + signatures_and_public_keys + .into_iter() + .map(convert) + .collect::, Self::Error>>()?, + fee_witness.map(convert).transpose()?, + )) + } +} + // ============================================================================ // Transaction conversions // ============================================================================ @@ -476,20 +536,7 @@ impl TryFrom for lee::PublicTransaction { message, witness_set, } = value; - let WitnessSet { - signatures_and_public_keys, - proof: _, - } = witness_set; - - Ok(Self::new( - message.into(), - lee::public_transaction::WitnessSet::from_raw_parts( - signatures_and_public_keys - .into_iter() - .map(|(sig, pk)| Ok((sig.into(), pk.try_into()?))) - .collect::, Self::Error>>()?, - ), - )) + Ok(Self::new(message.into(), witness_set.try_into()?)) } } @@ -526,19 +573,29 @@ impl TryFrom for lee::PrivacyPreservingTransaction impl From for ProgramDeploymentTransaction { fn from(value: lee::ProgramDeploymentTransaction) -> Self { let hash = HashType(value.hash()); - let lee::ProgramDeploymentTransaction { message } = value; + let lee::ProgramDeploymentTransaction { + message, + witness_set, + } = value; Self { hash, message: message.into(), + witness_set: witness_set.into(), } } } -impl From for lee::ProgramDeploymentTransaction { - fn from(value: ProgramDeploymentTransaction) -> Self { - let ProgramDeploymentTransaction { hash: _, message } = value; - Self::new(message.into()) +impl TryFrom for lee::ProgramDeploymentTransaction { + type Error = lee::error::LeeError; + + fn try_from(value: ProgramDeploymentTransaction) -> Result { + let ProgramDeploymentTransaction { + hash: _, + message, + witness_set, + } = value; + Ok(Self::new(message.into(), witness_set.try_into()?)) } } @@ -563,7 +620,7 @@ impl TryFrom for common::transaction::LeeTransaction { match value { Transaction::Public(tx) => Ok(Self::Public(tx.try_into()?)), Transaction::PrivacyPreserving(tx) => Ok(Self::PrivacyPreserving(tx.try_into()?)), - Transaction::ProgramDeployment(tx) => Ok(Self::ProgramDeployment(tx.into())), + Transaction::ProgramDeployment(tx) => Ok(Self::ProgramDeployment(tx.try_into()?)), } } } @@ -579,6 +636,7 @@ impl From for BlockHeader { prev_block_hash, hash, timestamp, + producer, signature, } = value; Self { @@ -586,6 +644,7 @@ impl From for BlockHeader { prev_block_hash: prev_block_hash.into(), hash: hash.into(), timestamp, + producer: producer.into(), signature: signature.into(), } } @@ -600,6 +659,7 @@ impl TryFrom for common::block::BlockHeader { prev_block_hash, hash, timestamp, + producer, signature, } = value; Ok(Self { @@ -607,6 +667,7 @@ impl TryFrom for common::block::BlockHeader { prev_block_hash: prev_block_hash.into(), hash: hash.into(), timestamp, + producer: producer.try_into()?, signature: signature.into(), }) } @@ -771,6 +832,11 @@ impl From for BlockIngestError { header: header.into(), } } + indexer_core::BlockIngestError::InvalidProducerSignature { producer } => { + Self::InvalidProducerSignature { + producer: producer.into(), + } + } indexer_core::BlockIngestError::EmptyBlock => Self::EmptyBlock, indexer_core::BlockIngestError::InvalidClockTransaction => { Self::InvalidClockTransaction diff --git a/lez/indexer/service/protocol/src/lib.rs b/lez/indexer/service/protocol/src/lib.rs index fe1fa525a..bc5021ac3 100644 --- a/lez/indexer/service/protocol/src/lib.rs +++ b/lez/indexer/service/protocol/src/lib.rs @@ -153,6 +153,8 @@ pub struct BlockHeader { pub prev_block_hash: HashType, pub hash: HashType, pub timestamp: Timestamp, + /// Block-signing public key of the sequencer that produced the block. + pub producer: PublicKey, pub signature: Signature, } @@ -222,6 +224,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; @@ -256,6 +262,9 @@ pub struct PrivacyPreservingMessage { pub struct WitnessSet { pub signatures_and_public_keys: Vec<(Signature, PublicKey)>, pub proof: Option, + /// Fee authorization for a payer outside the witness set. Public and + /// program-deployment transactions only. + pub fee_witness: Option<(Signature, PublicKey)>, } #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] @@ -276,6 +285,7 @@ pub struct EncryptedAccountData { pub struct ProgramDeploymentTransaction { pub hash: HashType, pub message: ProgramDeploymentMessage, + pub witness_set: WitnessSet, } pub type ViewTag = u8; @@ -341,6 +351,10 @@ pub struct ProgramDeploymentMessage { #[serde(with = "base64")] #[schemars(with = "String", description = "base64-encoded program bytecode")] pub bytecode: Vec, + pub payer: AccountId, + pub gas_limit: u64, + pub tip: u64, + pub max_fee: u128, } #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] @@ -411,6 +425,9 @@ pub enum BlockIngestError { computed: HashType, header: HashType, }, + InvalidProducerSignature { + producer: PublicKey, + }, EmptyBlock, InvalidClockTransaction, NonPublicGenesisTransaction, diff --git a/lez/indexer/service/src/mock_service.rs b/lez/indexer/service/src/mock_service.rs index c9a1912a1..70ed8089f 100644 --- a/lez/indexer/service/src/mock_service.rs +++ b/lez/indexer/service/src/mock_service.rs @@ -13,7 +13,8 @@ use indexer_service_protocol::{ CommitmentSetDigest, Data, EncryptedAccountData, HashType, IndexerStatus, IndexerSyncState, PrivacyPreservingMessage, PrivacyPreservingTransaction, PrivateAction, ProgramDeploymentMessage, ProgramDeploymentTransaction, ProgramId, PublicActionWithID, - PublicMessage, PublicTransaction, Signature, Transaction, ValidityWindow, WitnessSet, + PublicKey, PublicMessage, PublicTransaction, Signature, Transaction, ValidityWindow, + WitnessSet, }; use jsonrpsee::{ core::{SubscriptionResult, async_trait}, @@ -366,10 +367,15 @@ fn mock_public_tx( ], nonces: vec![block_id as u128, (block_id + 1) as u128], instruction_data: vec![1, 2, 3, 4], + payer: account_ids[tx_idx as usize % account_ids.len()], + gas_limit: 60_000, + tip: 100, + max_fee: 1_000_000, }, witness_set: WitnessSet { signatures_and_public_keys: vec![], proof: None, + fee_witness: None, }, }) } @@ -409,6 +415,7 @@ fn mock_privacy_preserving_tx( witness_set: WitnessSet { signatures_and_public_keys: vec![], proof: Some(indexer_service_protocol::Proof(vec![0; 32])), + fee_witness: None, }, }) } @@ -418,6 +425,15 @@ fn mock_program_deployment_tx(tx_hash: HashType) -> Transaction { hash: tx_hash, message: ProgramDeploymentMessage { bytecode: vec![0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00], + payer: AccountId { value: [0; 32] }, + gas_limit: 0, + tip: 0, + max_fee: 0, + }, + witness_set: WitnessSet { + signatures_and_public_keys: vec![], + proof: None, + fee_witness: None, }, }) } @@ -462,6 +478,8 @@ fn build_mock_block( prev_block_hash: prev_hash, hash: block_hash, timestamp, + // Fake feed: zeroed like the signature beside it, not a real key. + producer: PublicKey([0_u8; 32]), signature: Signature([0_u8; 64]), }, body: BlockBody { diff --git a/lez/programs/amm/src/tests.rs b/lez/programs/amm/src/tests.rs index e98c33a09..8172e1660 100644 --- a/lez/programs/amm/src/tests.rs +++ b/lez/programs/amm/src/tests.rs @@ -3112,7 +3112,7 @@ fn simple_amm_remove() { min_amount_to_remove_token_b: BalanceForExeTests::remove_min_amount_b(), }; - let message = public_transaction::Message::try_new( + let message = public_transaction::Message::new_feeless( programs::amm().id(), vec![ IdForExeTests::pool_definition_id(), @@ -3125,8 +3125,7 @@ fn simple_amm_remove() { ], vec![0_u128.into()], instruction, - ) - .unwrap(); + ); let witness_set = public_transaction::WitnessSet::for_message( &message, @@ -3189,7 +3188,7 @@ fn simple_amm_new_definition_inactive_initialized_pool_and_uninit_user_lp() { amm_program_id: programs::amm().id(), }; - let message = public_transaction::Message::try_new( + let message = public_transaction::Message::new_feeless( programs::amm().id(), vec![ IdForExeTests::pool_definition_id(), @@ -3202,8 +3201,7 @@ fn simple_amm_new_definition_inactive_initialized_pool_and_uninit_user_lp() { ], vec![0_u128.into(), 0_u128.into(), 0_u128.into()], instruction, - ) - .unwrap(); + ); let witness_set = public_transaction::WitnessSet::for_message( &message, @@ -3274,7 +3272,7 @@ fn simple_amm_new_definition_inactive_initialized_pool_init_user_lp() { amm_program_id: programs::amm().id(), }; - let message = public_transaction::Message::try_new( + let message = public_transaction::Message::new_feeless( programs::amm().id(), vec![ IdForExeTests::pool_definition_id(), @@ -3287,8 +3285,7 @@ fn simple_amm_new_definition_inactive_initialized_pool_init_user_lp() { ], vec![0_u128.into(), 0_u128.into()], instruction, - ) - .unwrap(); + ); let witness_set = public_transaction::WitnessSet::for_message( &message, @@ -3346,7 +3343,7 @@ fn simple_amm_new_definition_uninitialized_pool() { amm_program_id: programs::amm().id(), }; - let message = public_transaction::Message::try_new( + let message = public_transaction::Message::new_feeless( programs::amm().id(), vec![ IdForExeTests::pool_definition_id(), @@ -3359,8 +3356,7 @@ fn simple_amm_new_definition_uninitialized_pool() { ], vec![0_u128.into(), 0_u128.into(), 0_u128.into()], instruction, - ) - .unwrap(); + ); let witness_set = public_transaction::WitnessSet::for_message( &message, @@ -3409,7 +3405,7 @@ fn simple_amm_add() { max_amount_to_add_token_b: BalanceForExeTests::add_max_amount_b(), }; - let message = public_transaction::Message::try_new( + let message = public_transaction::Message::new_feeless( programs::amm().id(), vec![ IdForExeTests::pool_definition_id(), @@ -3422,8 +3418,7 @@ fn simple_amm_add() { ], vec![0_u128.into(), 0_u128.into()], instruction, - ) - .unwrap(); + ); let witness_set = public_transaction::WitnessSet::for_message( &message, @@ -3471,7 +3466,7 @@ fn simple_amm_swap_1() { token_definition_id_in: IdForExeTests::token_b_definition_id(), }; - let message = public_transaction::Message::try_new( + let message = public_transaction::Message::new_feeless( programs::amm().id(), vec![ IdForExeTests::pool_definition_id(), @@ -3482,8 +3477,7 @@ fn simple_amm_swap_1() { ], vec![0_u128.into()], instruction, - ) - .unwrap(); + ); let witness_set = public_transaction::WitnessSet::for_message( &message, @@ -3521,7 +3515,7 @@ fn simple_amm_swap_2() { min_amount_out: BalanceForExeTests::swap_min_amount_out(), token_definition_id_in: IdForExeTests::token_a_definition_id(), }; - let message = public_transaction::Message::try_new( + let message = public_transaction::Message::new_feeless( programs::amm().id(), vec![ IdForExeTests::pool_definition_id(), @@ -3532,8 +3526,7 @@ fn simple_amm_swap_2() { ], vec![0_u128.into()], instruction, - ) - .unwrap(); + ); let witness_set = public_transaction::WitnessSet::for_message( &message, diff --git a/lez/sequencer/core/src/block_store.rs b/lez/sequencer/core/src/block_store.rs index b11ed3e46..406065452 100644 --- a/lez/sequencer/core/src/block_store.rs +++ b/lez/sequencer/core/src/block_store.rs @@ -24,6 +24,9 @@ pub struct SequencerStore { tx_hash_to_block_map: HashMap, genesis_id: u64, signing_key: lee::PrivateKey, + /// Derived from `signing_key` once, since every block produced carries it + /// and deriving it is an elliptic-curve multiplication. + producer_key: lee::PublicKey, } impl SequencerStore { @@ -61,6 +64,7 @@ impl SequencerStore { dbio, tx_hash_to_block_map, genesis_id, + producer_key: lee::PublicKey::new_from_private_key(&signing_key), signing_key, }) } @@ -93,6 +97,7 @@ impl SequencerStore { dbio, tx_hash_to_block_map, genesis_id, + producer_key: lee::PublicKey::new_from_private_key(&signing_key), signing_key, }) } @@ -150,6 +155,13 @@ impl SequencerStore { &self.signing_key } + /// Public key this sequencer stamps into `header.producer`, matching + /// [`Self::signing_key`]. + #[must_use] + pub const fn producer_key(&self) -> &lee::PublicKey { + &self.producer_key + } + pub fn get_all_blocks(&self) -> impl Iterator> { self.dbio.get_all_blocks() } @@ -285,7 +297,10 @@ pub fn clear_cross_zone_peer_floor(dbio: &RocksDBIO, peer_zone: PeerZoneKey) -> #[cfg(test)] mod tests { - use common::{block::HashableBlockData, test_utils::sequencer_sign_key_for_testing}; + use common::{ + block::HashableBlockData, + test_utils::{sequencer_producer_key_for_testing, sequencer_sign_key_for_testing}, + }; use tempfile::tempdir; use super::*; @@ -301,6 +316,7 @@ mod tests { block_id: 0, prev_block_hash: HashType([0; 32]), timestamp: 0, + producer: sequencer_producer_key_for_testing(), transactions: vec![], }; @@ -339,6 +355,7 @@ mod tests { block_id: 0, prev_block_hash: HashType([0; 32]), timestamp: 0, + producer: sequencer_producer_key_for_testing(), transactions: vec![], }; @@ -369,6 +386,7 @@ mod tests { block_id: 0, prev_block_hash: HashType([0; 32]), timestamp: 0, + producer: sequencer_producer_key_for_testing(), transactions: vec![], }; @@ -405,6 +423,7 @@ mod tests { block_id: 0, prev_block_hash: HashType([0; 32]), timestamp: 0, + producer: sequencer_producer_key_for_testing(), transactions: vec![], }; @@ -454,6 +473,7 @@ mod tests { block_id: 0, prev_block_hash: HashType([0; 32]), timestamp: 0, + producer: sequencer_producer_key_for_testing(), transactions: vec![], }; diff --git a/lez/sequencer/core/src/cross_zone_watcher.rs b/lez/sequencer/core/src/cross_zone_watcher.rs index e21641a6a..943589193 100644 --- a/lez/sequencer/core/src/cross_zone_watcher.rs +++ b/lez/sequencer/core/src/cross_zone_watcher.rs @@ -166,8 +166,12 @@ fn link_against( expected_pubkey: Option<&PublicKey>, ) -> Link { // The channel authorizes who may write, not what they may claim, so the - // pinned key is what says this node's own sequencer produced the block. - if expected_pubkey.is_some_and(|key| !block.is_signed_by(key)) { + // pinned key is what says this node's own sequencer produced the block. The + // header's own `producer` is checked against it as well: the signature is + // what proves authorship, the comparison keeps the pinned key in charge of + // which producer is acceptable here. + if expected_pubkey.is_some_and(|key| &block.header.producer != key || !block.is_signed_by(key)) + { return Link::OffChain("block-signing key does not match the pinned key".to_owned()); } @@ -703,8 +707,7 @@ mod tests { payload: b"hi".to_vec(), ordinal: 0, }; - let message = Message::try_new(programs::ping_sender().id(), vec![], vec![], send) - .expect("emission serializes"); + let message = Message::new_feeless(programs::ping_sender().id(), vec![], vec![], send); LeeTransaction::Public(PublicTransaction::new( message, WitnessSet::from_raw_parts(vec![]), diff --git a/lez/sequencer/core/src/lib.rs b/lez/sequencer/core/src/lib.rs index be93f46ba..f2f52bc72 100644 --- a/lez/sequencer/core/src/lib.rs +++ b/lez/sequencer/core/src/lib.rs @@ -137,11 +137,15 @@ impl SequencerCore { let (genesis_state, genesis_txs) = build_genesis_state(config); + // Genesis is produced by this sequencer like any other block, so it + // names it as producer and is signed by it: no exemption is needed + // in the apply path, which validates genesis the same way. let hashable_data = HashableBlockData { block_id: GENESIS_BLOCK_ID, transactions: genesis_txs, prev_block_hash: HashType([0; 32]), timestamp: 0, + producer: lee::PublicKey::new_from_private_key(&signing_key), }; let genesis_block = hashable_data.into_pending_block(&signing_key); @@ -874,6 +878,7 @@ impl SequencerCore { transactions: temp_valid_transactions, prev_block_hash, timestamp: new_block_timestamp, + producer: self.store.producer_key().clone(), }; let block_size = borsh::to_vec(&temp_hashable_data) @@ -967,6 +972,7 @@ impl SequencerCore { transactions: valid_transactions, prev_block_hash, timestamp: new_block_timestamp, + producer: self.store.producer_key().clone(), }; let block = hashable_data @@ -1060,6 +1066,7 @@ impl SequencerCore { transactions: vec![tx.clone(), clock_tx.clone()], prev_block_hash, timestamp, + producer: self.store.producer_key().clone(), }; let size = borsh::to_vec(&alone) .context("Failed to serialize block for size check")? @@ -1542,6 +1549,7 @@ fn build_supply_account_genesis_transaction( recipient_id: *account_id, amount: balance, }, + lee::FeeFields::ZERO, ) .expect("Failed to serialize genesis transfer instruction"); let witness_set = lee::public_transaction::WitnessSet::from_raw_parts(vec![]); @@ -1558,6 +1566,7 @@ fn build_supply_bridge_account_genesis_transaction(balance: u128) -> PublicTrans vec![system_accounts::faucet_account_id(), bridge_account_id], vec![], faucet_core::Instruction::GenesisTransferDirect { amount: balance }, + lee::FeeFields::ZERO, ) .expect("Failed to serialize bridge genesis transfer instruction"); let witness_set = lee::public_transaction::WitnessSet::from_raw_parts(vec![]); @@ -1601,6 +1610,7 @@ fn build_bridge_deposit_tx_from_event(event: &PendingDepositEventRecord) -> Resu recipient_id: metadata.recipient_id, amount: event.amount, }, + lee::FeeFields::ZERO, ) .context("Failed to build bridge deposit message")?; diff --git a/lez/sequencer/core/src/tests.rs b/lez/sequencer/core/src/tests.rs index ae0a6c31b..7e7701fd0 100644 --- a/lez/sequencer/core/src/tests.rs +++ b/lez/sequencer/core/src/tests.rs @@ -281,6 +281,7 @@ async fn start_from_config_opens_existing_db_if_it_exists() { transactions: genesis_txs, prev_block_hash: HashType([0; 32]), timestamp: 0, + producer: lee::PublicKey::new_from_private_key(&signing_key), }; let genesis_block = genesis_hashable_data.into_pending_block(&signing_key); @@ -1352,13 +1353,12 @@ async fn transactions_touching_clock_account_are_dropped_from_block() { // Canonical clock invocation and a crafted variant with a different timestamp — both must // be dropped because their diffs touch the clock accounts. let crafted_clock_tx = { - let message = lee::public_transaction::Message::try_new( + let message = lee::public_transaction::Message::new_feeless( programs::clock().id(), system_accounts::clock_account_ids().to_vec(), vec![], 42_u64, - ) - .unwrap(); + ); LeeTransaction::Public(lee::PublicTransaction::new( message, lee::public_transaction::WitnessSet::from_raw_parts(vec![]), @@ -1399,7 +1399,10 @@ async fn user_tx_that_chain_calls_clock_is_dropped() { let clock_chain_caller = test_programs::clock_chain_caller(); // Deploy the clock_chain_caller test program. let deploy_tx = LeeTransaction::ProgramDeployment(lee::ProgramDeploymentTransaction::new( - lee::program_deployment_transaction::Message::new(clock_chain_caller.elf().to_owned()), + lee::program_deployment_transaction::Message::new_feeless( + clock_chain_caller.elf().to_owned(), + ), + lee::public_transaction::WitnessSet::from_raw_parts(vec![]), )); mempool_handle .push((TransactionOrigin::User, deploy_tx)) @@ -1414,13 +1417,12 @@ async fn user_tx_that_chain_calls_clock_is_dropped() { let clock_program_id = programs::clock().id(); let timestamp: u64 = 0; - let message = lee::public_transaction::Message::try_new( + let message = lee::public_transaction::Message::new_feeless( clock_chain_caller_id, system_accounts::clock_account_ids().to_vec(), vec![], // no signers (clock_program_id, timestamp), - ) - .unwrap(); + ); let user_tx = LeeTransaction::Public(lee::PublicTransaction::new( message, lee::public_transaction::WitnessSet::from_raw_parts(vec![]), @@ -1590,13 +1592,12 @@ fn time_locked_transfer_transaction( deadline: u64, ) -> PublicTransaction { let program_id = test_programs::time_locked_transfer().id(); - let message = lee::public_transaction::Message::try_new( + let message = lee::public_transaction::Message::new_feeless( program_id, vec![from, to, clock_account_id], vec![Nonce(from_nonce)], (amount, deadline), - ) - .unwrap(); + ); let witness_set = lee::public_transaction::WitnessSet::for_message(&message, &[from_key]); PublicTransaction::new(message, witness_set) } @@ -1717,13 +1718,12 @@ fn pinata_cooldown_transaction( clock_account_id: AccountId, ) -> PublicTransaction { let program_id = test_programs::pinata_cooldown().id(); - let message = lee::public_transaction::Message::try_new( + let message = lee::public_transaction::Message::new_feeless( program_id, vec![pinata_id, winner_id, clock_account_id], vec![], (), - ) - .unwrap(); + ); let witness_set = lee::public_transaction::WitnessSet::for_message(&message, &[]); PublicTransaction::new(message, witness_set) } @@ -1902,7 +1902,7 @@ fn pda_mechanism_with_pinata_token_program() { // Submit a solution to the pinata program to claim the prize let solution: u128 = 989_106; - let message = lee::public_transaction::Message::try_new( + let message = lee::public_transaction::Message::new_feeless( pinata_token.id(), vec![ pinata_definition_id, @@ -1911,8 +1911,7 @@ fn pda_mechanism_with_pinata_token_program() { ], vec![], solution, - ) - .unwrap(); + ); let witness_set = lee::public_transaction::WitnessSet::for_message(&message, &[]); let tx = PublicTransaction::new(message, witness_set); state.transition_from_public_transaction(&tx, 1, 0).unwrap(); @@ -1938,7 +1937,7 @@ fn resubmittable_txs_drops_clock_and_bridge_deposits() { }) .unwrap(); let withdraw_tx = { - let message = lee::public_transaction::Message::try_new( + let message = lee::public_transaction::Message::new_feeless( programs::bridge().id(), vec![system_accounts::bridge_account_id()], vec![], @@ -1946,8 +1945,7 @@ fn resubmittable_txs_drops_clock_and_bridge_deposits() { amount: 1, bedrock_account_pk: [0; 32], }, - ) - .unwrap(); + ); LeeTransaction::Public(PublicTransaction::new( message, lee::public_transaction::WitnessSet::from_raw_parts(vec![]), @@ -1972,6 +1970,7 @@ fn resubmittable_txs_of_blocks_without_user_txs_is_empty() { block_id: 1, prev_block_hash: HashType([0; 32]), timestamp: 0, + producer: common::test_utils::sequencer_producer_key_for_testing(), transactions: vec![], } .into_pending_block(&sequencer_sign_key_for_testing()); diff --git a/lez/sequencer/core/src/tests/reconstruction.rs b/lez/sequencer/core/src/tests/reconstruction.rs index 6365fca84..8a081a57a 100644 --- a/lez/sequencer/core/src/tests/reconstruction.rs +++ b/lez/sequencer/core/src/tests/reconstruction.rs @@ -463,6 +463,7 @@ fn deposit_event_record( // amount, // bedrock_account_pk, // }, +// lee::FeeFields::ZERO, // ) // .unwrap(); // let witness_set = lee::public_transaction::WitnessSet::for_message(&message, &[signing_key]); diff --git a/lez/wallet/src/lib.rs b/lez/wallet/src/lib.rs index 4eda2fc5d..1d4fe6f58 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(); @@ -879,8 +880,13 @@ impl WalletCore { } pub async fn send_program_deployment_transaction(&self, bytecode: Vec) -> Result { - let message = lee::program_deployment_transaction::Message::new(bytecode); - let transaction = ProgramDeploymentTransaction::new(message); + // TODO(T8): designate and sign for a real fee payer once deployments are charged. + let message = + lee::program_deployment_transaction::Message::new(bytecode, lee::FeeFields::ZERO); + let transaction = ProgramDeploymentTransaction::new( + message, + lee::public_transaction::WitnessSet::from_raw_parts(vec![]), + ); Ok(self .multi_sequencer_client diff --git a/tools/cross_zone_chat/src/main.rs b/tools/cross_zone_chat/src/main.rs index 88f6d3856..8811541a2 100644 --- a/tools/cross_zone_chat/src/main.rs +++ b/tools/cross_zone_chat/src/main.rs @@ -501,6 +501,7 @@ fn build_send_tx(other_zone: ZoneId, ordinal: u32, text: &str) -> LeeTransaction vec![outbox_account], vec![], send, + lee::FeeFields::ZERO, ) .expect("build ping_sender message");