mirror of
https://github.com/logos-blockchain/logos-execution-zone.git
synced 2026-08-26 11:51:13 +00:00
feat(fees): fee fields and payer authorization on the wire format
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -858,6 +858,7 @@ impl WalletCore {
|
||||
account_ids,
|
||||
nonces,
|
||||
instruction_data,
|
||||
lee::FeeFields::ZERO,
|
||||
);
|
||||
|
||||
let message_hash = message.hash();
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user