feat(fees): fee fields and payer authorization on the wire format

This commit is contained in:
erhant
2026-08-21 14:56:08 +03:00
parent 1a0a03c908
commit 390abe2ffb
13 changed files with 302 additions and 4 deletions
+165
View File
@@ -0,0 +1,165 @@
//! Fee fields and payer authorization shared by signed message kinds.
use borsh::{BorshDeserialize, BorshSerialize};
use crate::{AccountId, public_transaction::WitnessSet};
/// The payer recorded on system-generated transactions, which are fee-exempt.
pub const SYSTEM_PAYER: AccountId = AccountId::new([0_u8; 32]);
/// The signed fee fields of a transaction message. Stored flat on each message
/// kind and covered by its hash, so every witness signature authorizes them.
#[derive(Debug, Clone, Copy, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
pub struct FeeFields {
/// The account debited for this transaction's fee. Never inferred from the
/// witness set: it must be designated here and authorized by a signature.
pub payer: AccountId,
pub gas_limit: u64,
pub tip: u64,
pub max_fee: u128,
}
impl FeeFields {
pub const ZERO: Self = Self::new(SYSTEM_PAYER, 0, 0, 0);
#[must_use]
pub const fn new(payer: AccountId, gas_limit: u64, tip: u64, max_fee: u128) -> Self {
Self {
payer,
gas_limit,
tip,
max_fee,
}
}
}
/// A message kind that carries fee fields under a domain-separated hash.
pub trait SignedMessage {
fn signing_hash(&self) -> [u8; 32];
fn payer(&self) -> AccountId;
}
/// Accounts whose valid signature over `message` accompanies the transaction:
/// the ordinary witnesses plus the fee witness, if any.
#[must_use]
pub fn fee_authorized_account_ids<M: SignedMessage>(
message: &M,
witness_set: &WitnessSet,
) -> Vec<AccountId> {
let message_hash = message.signing_hash();
witness_set
.signatures_and_public_keys()
.iter()
.chain(witness_set.fee_witness())
.filter(|(signature, public_key)| signature.is_valid_for(&message_hash, public_key))
.map(|(_, public_key)| AccountId::from(public_key))
.collect()
}
/// Whether the designated payer's authorization accompanies the transaction.
#[must_use]
pub fn is_fee_authorized<M: SignedMessage>(message: &M, witness_set: &WitnessSet) -> bool {
fee_authorized_account_ids(message, witness_set).contains(&message.payer())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
PrivateKey, PublicKey,
public_transaction::{Message, WitnessSet},
};
fn keys() -> (PrivateKey, PrivateKey) {
(
PrivateKey::try_new([1_u8; 32]).expect("valid key"),
PrivateKey::try_new([2_u8; 32]).expect("valid key"),
)
}
fn account_id_of(key: &PrivateKey) -> AccountId {
AccountId::from(&PublicKey::new_from_private_key(key))
}
fn message_with_fees(fees: FeeFields) -> Message {
let (signer_key, _) = keys();
Message::try_new_with_fees(
[0_u32; 8],
vec![account_id_of(&signer_key)],
vec![0_u128.into()],
vec![1_u8, 2, 3],
fees,
)
.expect("valid message")
}
#[test]
fn self_payer_is_authorized_by_ordinary_signature() {
let (signer_key, _) = keys();
let message =
message_with_fees(FeeFields::new(account_id_of(&signer_key), 1_000, 0, 10_000));
let witness_set = WitnessSet::for_message(&message, &[&signer_key]);
assert!(is_fee_authorized(&message, &witness_set));
}
#[test]
fn sponsor_payer_requires_the_fee_witness() {
let (signer_key, sponsor_key) = keys();
let message = message_with_fees(FeeFields::new(
account_id_of(&sponsor_key),
1_000,
0,
10_000,
));
let without = WitnessSet::for_message(&message, &[&signer_key]);
assert!(!is_fee_authorized(&message, &without));
let with = WitnessSet::for_message(&message, &[&signer_key])
.with_fee_signer(&message, &sponsor_key);
assert!(is_fee_authorized(&message, &with));
assert!(with.is_valid_for(&message));
}
#[test]
fn tampered_fee_fields_invalidate_every_signature() {
// The fee fields are inside the signed hash: raising gas_limit after
// signing must break both the ordinary and the fee-witness signatures.
let (signer_key, sponsor_key) = keys();
let message = message_with_fees(FeeFields::new(
account_id_of(&sponsor_key),
1_000,
0,
10_000,
));
let witness_set = WitnessSet::for_message(&message, &[&signer_key])
.with_fee_signer(&message, &sponsor_key);
let mut tampered = message;
tampered.gas_limit = 999_999;
assert!(!witness_set.is_valid_for(&tampered));
assert!(!is_fee_authorized(&tampered, &witness_set));
}
#[test]
fn invalid_sponsor_signature_invalidates_the_witness_set() {
let (signer_key, sponsor_key) = keys();
let message = message_with_fees(FeeFields::ZERO);
let other =
Message::try_new_with_fees([1_u32; 8], vec![], vec![], vec![9_u8], FeeFields::ZERO)
.expect("valid message");
// Fee witness signs a DIFFERENT message: the set must be invalid.
let witness_set =
WitnessSet::for_message(&message, &[&signer_key]).with_fee_signer(&other, &sponsor_key);
assert!(!witness_set.is_valid_for(&message));
}
#[test]
fn zero_fields_round_trip_and_default_constructor_matches() {
let message = message_with_fees(FeeFields::ZERO);
assert_eq!(message.fees(), FeeFields::ZERO);
let bytes = borsh::to_vec(&message).expect("serializes");
let back: Message = borsh::from_slice(&bytes).expect("deserializes");
assert_eq!(back, message);
}
}
+4
View File
@@ -3,6 +3,9 @@
reason = "We prefer to group methods by functionality rather than by type for encoding"
)]
pub use fees::{
FeeFields, SYSTEM_PAYER, SignedMessage, fee_authorized_account_ids, is_fee_authorized,
};
pub use lee_core::{
GENESIS_BLOCK_ID, SharedSecretKey,
account::{Account, AccountId, Balance, Data, Fee, Gas},
@@ -23,6 +26,7 @@ pub use validated_state_diff::ValidatedStateDiff;
pub mod encoding;
pub mod error;
pub mod fees;
mod merkle_tree;
pub mod privacy_preserving_transaction;
pub mod program;
@@ -6,7 +6,7 @@ use lee_core::{
use serde::Serialize;
use sha2::{Digest as _, Sha256};
use crate::{AccountId, error::LeeError, program::Program};
use crate::{AccountId, error::LeeError, fees::FeeFields, program::Program};
const PREFIX: &[u8; 32] = b"/LEE/v0.3/Message/Public/\x00\x00\x00\x00\x00\x00\x00";
@@ -16,6 +16,10 @@ pub struct Message {
pub account_ids: Vec<AccountId>,
pub nonces: Vec<Nonce>,
pub instruction_data: InstructionData,
pub payer: AccountId,
pub gas_limit: u64,
pub tip: u64,
pub max_fee: u128,
}
impl std::fmt::Debug for Message {
@@ -31,25 +35,49 @@ impl std::fmt::Debug for Message {
.field("account_ids", &self.account_ids)
.field("nonces", &self.nonces)
.field("instruction_data", &self.instruction_data)
.field("payer", &self.payer)
.field("gas_limit", &self.gas_limit)
.field("tip", &self.tip)
.field("max_fee", &self.max_fee)
.finish()
}
}
impl Message {
/// Builds a message with zero fee fields. Correct for exempt system
/// transactions and while the zero-fee policy holds; charged transactions
/// use [`Self::try_new_with_fees`].
pub fn try_new<T: Serialize>(
program_id: ProgramId,
account_ids: Vec<AccountId>,
nonces: Vec<Nonce>,
instruction: T,
) -> Result<Self, LeeError> {
Self::try_new_with_fees(
program_id,
account_ids,
nonces,
instruction,
FeeFields::ZERO,
)
}
pub fn try_new_with_fees<T: Serialize>(
program_id: ProgramId,
account_ids: Vec<AccountId>,
nonces: Vec<Nonce>,
instruction: T,
fees: FeeFields,
) -> Result<Self, LeeError> {
let instruction_data = Program::serialize_instruction(instruction)?;
Ok(Self {
Ok(Self::new_preserialized(
program_id,
account_ids,
nonces,
instruction_data,
})
fees,
))
}
#[must_use]
@@ -58,15 +86,25 @@ impl Message {
account_ids: Vec<AccountId>,
nonces: Vec<Nonce>,
instruction_data: InstructionData,
fees: FeeFields,
) -> Self {
Self {
program_id,
account_ids,
nonces,
instruction_data,
payer: fees.payer,
gas_limit: fees.gas_limit,
tip: fees.tip,
max_fee: fees.max_fee,
}
}
#[must_use]
pub const fn fees(&self) -> FeeFields {
FeeFields::new(self.payer, self.gas_limit, self.tip, self.max_fee)
}
#[must_use]
pub fn hash(&self) -> [u8; 32] {
let mut bytes = Vec::with_capacity(
@@ -82,6 +120,16 @@ impl Message {
}
}
impl crate::fees::SignedMessage for Message {
fn signing_hash(&self) -> [u8; 32] {
self.hash()
}
fn payer(&self) -> AccountId {
self.payer
}
}
#[cfg(test)]
mod tests {
use lee_core::account::{AccountId, Nonce};
@@ -96,6 +144,7 @@ mod tests {
vec![AccountId::new([42_u8; 32])],
vec![Nonce(5)],
vec![],
crate::fees::FeeFields::ZERO,
);
// program_id: [1_u32; 8], each word as LE u32
@@ -108,6 +157,9 @@ mod tests {
// nonces: u32 len=1, then Nonce(5) as LE u128
let nonces_bytes: &[u8] = &[1, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
let instruction_data_bytes: &[u8] = &[0_u8; 4];
// Fee fields (FeeFields::ZERO): payer (32 zero bytes), then gas_limit
// (u64 LE), tip (u64 LE), max_fee (u128 LE), all zero.
let fee_fields_bytes: &[u8] = &[0_u8; 32 + 8 + 8 + 16];
let expected_borsh_vec: Vec<u8> = [
program_id_bytes,
@@ -115,6 +167,7 @@ mod tests {
account_ids_bytes,
nonces_bytes,
instruction_data_bytes,
fee_fields_bytes,
]
.concat();
let expected_borsh: &[u8] = &expected_borsh_vec;
@@ -251,6 +251,7 @@ pub mod tests {
vec![],
vec![],
vec![0; 4],
crate::fees::FeeFields::ZERO,
);
let witness_set = WitnessSet::from_raw_parts(vec![]);
let tx = PublicTransaction::new(message, witness_set);
@@ -5,6 +5,7 @@ use crate::{PrivateKey, PublicKey, Signature, public_transaction::Message};
#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
pub struct WitnessSet {
pub(crate) signatures_and_public_keys: Vec<(Signature, PublicKey)>,
pub(crate) fee_witness: Option<(Signature, PublicKey)>,
}
impl WitnessSet {
@@ -22,13 +23,35 @@ impl WitnessSet {
.collect();
Self {
signatures_and_public_keys,
fee_witness: None,
}
}
/// Adds a sponsor's fee authorization: a signature over the same message
/// hash by an account outside the ordinary witness set.
#[must_use]
pub fn with_fee_signer(mut self, message: &Message, payer_key: &PrivateKey) -> Self {
let message_hash = message.hash();
self.fee_witness = Some((
Signature::new(payer_key, &message_hash),
PublicKey::new_from_private_key(payer_key),
));
self
}
#[must_use]
pub const fn fee_witness(&self) -> Option<&(Signature, PublicKey)> {
self.fee_witness.as_ref()
}
#[must_use]
pub fn is_valid_for(&self, message: &Message) -> bool {
let message_hash = message.hash();
for (signature, public_key) in self.signatures_and_public_keys() {
for (signature, public_key) in self
.signatures_and_public_keys()
.iter()
.chain(self.fee_witness())
{
if !signature.is_valid_for(&message_hash, public_key) {
return false;
}
@@ -50,6 +73,7 @@ impl WitnessSet {
pub const fn from_raw_parts(signatures_and_public_keys: Vec<(Signature, PublicKey)>) -> Self {
Self {
signatures_and_public_keys,
fee_witness: None,
}
}
}
@@ -19,6 +19,10 @@ pub fn PublicTxDetails(tx: PublicTransaction) -> impl IntoView {
account_ids,
nonces,
instruction_data,
payer,
gas_limit,
tip,
max_fee,
} = message;
let WitnessSet {
signatures_and_public_keys,
@@ -51,6 +55,16 @@ pub fn PublicTxDetails(tx: PublicTransaction) -> impl IntoView {
<span class="info-label">"Signatures:"</span>
<span class="info-value">{signatures_count.to_string()}</span>
</div>
<div class="info-row">
<span class="info-label">"Fee Payer:"</span>
<span class="info-value hash">{payer.to_string()}</span>
</div>
<div class="info-row">
<span class="info-label">"Gas Limit / Tip / Max Fee:"</span>
<span class="info-value">
{format!("{gas_limit} / {tip} / {max_fee}")}
</span>
</div>
</div>
<h3>"Accounts"</h3>
@@ -65,6 +65,12 @@ impl From<Box<FfiPublicTransactionBody>> for PublicTransaction {
std_vec.into_iter().map(Into::into).collect()
},
instruction_data: value.message.instruction_data.into(),
// FIXME: The FFI surface does not carry fee fields yet;
// reconstruct them as zero (the exempt/system value).
payer: AccountId { value: [0; 32] },
gas_limit: 0,
tip: 0,
max_fee: 0,
},
witness_set: WitnessSet {
signatures_and_public_keys: {
@@ -100,6 +106,11 @@ impl From<PublicMessage> for FfiPublicMessage {
account_ids,
nonces,
instruction_data,
// FIXME: Not carried over the FFI yet.
payer: _,
gas_limit: _,
tip: _,
max_fee: _,
} = value;
Self {
@@ -249,12 +249,20 @@ impl From<lee::public_transaction::Message> 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<PublicMessage> 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<PublicMessage> 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),
)
}
}
+4
View File
@@ -222,6 +222,10 @@ pub struct PublicMessage {
pub account_ids: Vec<AccountId>,
pub nonces: Vec<Nonce>,
pub instruction_data: InstructionData,
pub payer: AccountId,
pub gas_limit: u64,
pub tip: u64,
pub max_fee: u128,
}
pub type InstructionData = Vec<u32>;
+4
View File
@@ -366,6 +366,10 @@ fn mock_public_tx(
],
nonces: vec![block_id as u128, (block_id + 1) as u128],
instruction_data: vec![1, 2, 3, 4],
payer: AccountId { value: [0; 32] },
gas_limit: 0,
tip: 0,
max_fee: 0,
},
witness_set: WitnessSet {
signatures_and_public_keys: vec![],
@@ -9,6 +9,10 @@ use common::transaction::LeeTransaction;
const BLOCK_HEADER_OVERHEAD: u64 = 200;
#[derive(Debug)]
#[expect(
clippy::large_enum_variant,
reason = "Accept dominates and the value is transient per gossiped message; boxing would add an allocation on the hot validation path"
)]
pub enum TxEvaluation {
/// Structurally valid and authenticated; forward and admit.
Accept(LeeTransaction),
+1
View File
@@ -858,6 +858,7 @@ impl WalletCore {
account_ids,
nonces,
instruction_data,
lee::FeeFields::ZERO,
);
let message_hash = message.hash();
Binary file not shown.