Make transaction an enum of Unsigned, Signed

This commit is contained in:
Luke Parker
2025-02-26 06:54:42 -05:00
parent 820b710928
commit 3f03dac050

View File

@@ -1,5 +1,5 @@
use core::num::NonZero; use core::num::NonZero;
use alloc::{vec, vec::Vec}; use alloc::vec::Vec;
use borsh::{io, BorshSerialize, BorshDeserialize}; use borsh::{io, BorshSerialize, BorshDeserialize};
@@ -22,8 +22,12 @@ pub enum SignedCallsError {
} }
/// A `Vec` of signed calls. /// A `Vec` of signed calls.
#[derive(Clone, PartialEq, Eq, Debug)] // We don't implement BorshDeserialize due to to maintained invariants on this struct.
pub struct SignedCalls(BoundedVec<Call, ConstU32<{ MAX_CALLS }>>); #[derive(Clone, PartialEq, Eq, Debug, BorshSerialize)]
pub struct SignedCalls(
#[borsh(serialize_with = "serai_primitives::sp_borsh::borsh_serialize_bounded_vec")]
BoundedVec<Call, ConstU32<{ MAX_CALLS }>>,
);
impl TryFrom<Vec<Call>> for SignedCalls { impl TryFrom<Vec<Call>> for SignedCalls {
type Error = SignedCallsError; type Error = SignedCallsError;
fn try_from(calls: Vec<Call>) -> Result<Self, Self::Error> { fn try_from(calls: Vec<Call>) -> Result<Self, Self::Error> {
@@ -47,7 +51,8 @@ pub enum UnsignedCallError {
} }
/// An unsigned call. /// An unsigned call.
#[derive(Clone, PartialEq, Eq, Debug)] // We don't implement BorshDeserialize due to to maintained invariants on this struct.
#[derive(Clone, PartialEq, Eq, Debug, BorshSerialize)]
pub struct UnsignedCall(Call); pub struct UnsignedCall(Call);
impl TryFrom<Call> for UnsignedCall { impl TryFrom<Call> for UnsignedCall {
type Error = UnsignedCallError; type Error = UnsignedCallError;
@@ -102,70 +107,95 @@ pub struct ContextualizedSignature {
signature: Signature, signature: Signature,
} }
/// The Serai transaction type. /// A Serai transaction.
#[derive(Clone, PartialEq, Eq, Debug)] #[derive(Clone, PartialEq, Eq, Debug)]
pub struct Transaction { pub enum Transaction {
/// The calls, as defined in Serai's ABI. /// An unsigned transaction.
/// Unsigned {
/// These calls are executed atomically. Either all successfully execute or none do. The /// The contained call.
/// transaction's fee is paid regardless. call: UnsignedCall,
// TODO: if this is unsigned, we only allow a single call. Should we serialize that as 0? },
calls: BoundedVec<Call, ConstU32<{ MAX_CALLS }>>, /// A signed transaction.
/// The signature, if present. Signed {
contextualized_signature: Option<ContextualizedSignature>, /// The calls.
///
/// These calls are executed atomically. Either all successfully execute or none do. The
/// transaction's fee is paid regardless.
calls: SignedCalls,
/// The signature for this transaction.
///
/// This is not checked on deserializtion and may be invalid.
contextualized_signature: ContextualizedSignature,
},
} }
impl BorshSerialize for Transaction { impl BorshSerialize for Transaction {
fn serialize<W: io::Write>(&self, writer: &mut W) -> io::Result<()> { fn serialize<W: io::Write>(&self, writer: &mut W) -> io::Result<()> {
// Write the calls match self {
self.calls.serialize(writer)?; Transaction::Unsigned { call } => {
// Write the signature, if present. Presence is deterministic to the calls /*
if let Some(contextualized_signature) = self.contextualized_signature.as_ref() { `Signed` `Transaction`s encode the length of their `Vec<Call>` here. Since that `Vec` is
contextualized_signature.serialize(writer)?; bound to be non-empty, it will never write `0`, enabling `Unsigned` to use it.
The benefit to these not overlapping is in the ability to determine if the `Transaction`
has a signature or not. If this wrote a `1`, for the amount of `Call`s present in the
`Transaction`, that `Call` would have to be introspected for if its signed or not. With
the usage of `0`, given how low `MAX_CALLS` is, this `Transaction` can technically be
defined as an enum of
`0 Call, 1 Call ContextualizedSignature, 2 Call Call ContextualizedSignature ...`, to
maintain compatbility with the borsh specification without wrapper functions. The checks
here on `Call` types/quantity could be moved to later validation functions.
*/
writer.write_all(&[0])?;
call.serialize(writer)
}
Transaction::Signed { calls, contextualized_signature } => {
serai_primitives::sp_borsh::borsh_serialize_bounded_vec(&calls.0, writer)?;
contextualized_signature.serialize(writer)
}
} }
Ok(())
} }
} }
impl BorshDeserialize for Transaction { impl BorshDeserialize for Transaction {
fn deserialize_reader<R: io::Read>(reader: &mut R) -> io::Result<Self> { fn deserialize_reader<R: io::Read>(reader: &mut R) -> io::Result<Self> {
// Read the calls let mut len = [0xff];
let calls = reader.read_exact(&mut len)?;
serai_primitives::sp_borsh::borsh_deserialize_bounded_vec::<_, Call, MAX_CALLS>(reader)?; let len = len[0];
// Determine if this is signed or unsigned if len == 0 {
let mut signed = None; let call = Call::deserialize_reader(reader)?;
for call in &calls { if call.is_signed() {
let call_is_signed = call.is_signed(); Err(io::Error::new(io::ErrorKind::Other, "call was signed but marked unsigned"))?;
if signed.is_none() {
signed = Some(call_is_signed)
};
if signed != Some(call_is_signed) {
Err(io::Error::new(io::ErrorKind::Other, "calls were a mixture of signed and unsigned"))?;
} }
Ok(Transaction::Unsigned { call: UnsignedCall(call) })
} else {
if u32::from(len) > MAX_CALLS {
Err(io::Error::new(io::ErrorKind::Other, "too many calls"))?;
}
let mut calls = BoundedVec::with_bounded_capacity(len.into());
for _ in 0 .. len {
let call = Call::deserialize_reader(reader)?;
if !call.is_signed() {
Err(io::Error::new(io::ErrorKind::Other, "call was unsigned but included as signed"))?;
}
calls.try_push(call).unwrap();
}
let contextualized_signature = ContextualizedSignature::deserialize_reader(reader)?;
Ok(Transaction::Signed { calls: SignedCalls(calls), contextualized_signature })
} }
let Some(signed) = signed else {
Err(io::Error::new(io::ErrorKind::Other, "transaction had no calls"))?
};
// Read the signature, if these calls are signed
let contextualized_signature =
if signed { Some(<ContextualizedSignature>::deserialize_reader(reader)?) } else { None };
Ok(Transaction { calls, contextualized_signature })
} }
} }
impl Transaction { impl Transaction {
/// The message to sign to produce a signature, for calls which may or may not be signed and are /// The message to sign to produce a signature.
/// unchecked. pub fn signature_message(
fn signature_message_unchecked( calls: &SignedCalls,
calls: &[Call],
implicit_context: &ImplicitContext, implicit_context: &ImplicitContext,
explicit_context: &ExplicitContext, explicit_context: &ExplicitContext,
) -> Vec<u8> { ) -> Vec<u8> {
let mut message = Vec::with_capacity( let mut message = Vec::with_capacity(
(calls.len() * 64) + (calls.0.len() * 64) +
core::mem::size_of::<ImplicitContext>() + core::mem::size_of::<ImplicitContext>() +
core::mem::size_of::<ExplicitContext>(), core::mem::size_of::<ExplicitContext>(),
); );
@@ -174,49 +204,12 @@ impl Transaction {
explicit_context.serialize(&mut message).unwrap(); explicit_context.serialize(&mut message).unwrap();
message message
} }
/// The message to sign to produce a signature.
pub fn signature_message(
calls: &SignedCalls,
implicit_context: &ImplicitContext,
explicit_context: &ExplicitContext,
) -> Vec<u8> {
Self::signature_message_unchecked(&calls.0, implicit_context, explicit_context)
}
/// A transaction with signed calls.
pub fn signed(
calls: SignedCalls,
explicit_context: ExplicitContext,
signature: Signature,
) -> Self {
let calls = calls.0;
Self {
calls,
contextualized_signature: Some(ContextualizedSignature { explicit_context, signature }),
}
}
/// A transaction with an unsigned call.
pub fn unsigned(call: UnsignedCall) -> Self {
let call = call.0;
Self {
calls: vec![call.clone()]
.try_into()
.expect("couldn't convert a length-1 Vec to a BoundedVec"),
contextualized_signature: None,
}
}
/// If the transaction is signed.
pub fn is_signed(&self) -> bool {
self.calls[0].is_signed()
}
} }
#[cfg(feature = "substrate")] #[cfg(feature = "substrate")]
mod substrate { mod substrate {
use core::{marker::PhantomData, fmt::Debug}; use core::{marker::PhantomData, fmt::Debug};
use alloc::vec;
use scale::{Encode, Decode}; use scale::{Encode, Decode};
use sp_runtime::{ use sp_runtime::{
@@ -298,31 +291,33 @@ mod substrate {
impl ExtrinsicLike for Transaction { impl ExtrinsicLike for Transaction {
fn is_signed(&self) -> Option<bool> { fn is_signed(&self) -> Option<bool> {
Some(Transaction::is_signed(self)) Some(matches!(self, Transaction::Signed { .. }))
} }
fn is_bare(&self) -> bool { fn is_bare(&self) -> bool {
!Transaction::is_signed(self) matches!(self, Transaction::Unsigned { .. })
} }
} }
impl<Context: TransactionContext> GetDispatchInfo for TransactionWithContext<Context> { impl<Context: TransactionContext> GetDispatchInfo for TransactionWithContext<Context> {
fn get_dispatch_info(&self) -> DispatchInfo { fn get_dispatch_info(&self) -> DispatchInfo {
let (extension_weight, class, pays_fee) = if Transaction::is_signed(&self.0) { match &self.0 {
(Context::SIGNED_WEIGHT, DispatchClass::Normal, Pays::Yes) Transaction::Unsigned { call } => DispatchInfo {
} else { call_weight: Context::RuntimeCall::from(call.0.clone()).get_dispatch_info().call_weight,
(Weight::zero(), DispatchClass::Operational, Pays::No) extension_weight: Weight::zero(),
}; class: DispatchClass::Operational,
DispatchInfo { pays_fee: Pays::No,
call_weight: self },
.0 Transaction::Signed { calls, .. } => DispatchInfo {
.calls call_weight: calls
.iter() .0
.cloned() .iter()
.map(|call| Context::RuntimeCall::from(call).get_dispatch_info().call_weight) .cloned()
.fold(Weight::zero(), |accum, item| accum + item), .map(|call| Context::RuntimeCall::from(call).get_dispatch_info().call_weight)
extension_weight, .fold(Weight::zero(), |accum, item| accum + item),
class, extension_weight: Context::SIGNED_WEIGHT,
pays_fee, class: DispatchClass::Normal,
pays_fee: Pays::Yes,
},
} }
} }
} }
@@ -331,47 +326,49 @@ mod substrate {
type Checked = TransactionWithContext<Context>; type Checked = TransactionWithContext<Context>;
fn check(self, context: &Context) -> Result<Self::Checked, TransactionValidityError> { fn check(self, context: &Context) -> Result<Self::Checked, TransactionValidityError> {
if let Some(ContextualizedSignature { explicit_context, signature }) = match &self {
&self.contextualized_signature Transaction::Unsigned { .. } => {}
{ Transaction::Signed {
let ExplicitContext { historic_block, include_by, signer, nonce, fee } = &explicit_context; calls,
if !context.block_is_present_in_blockchain(historic_block) { contextualized_signature: ContextualizedSignature { explicit_context, signature },
// We don't know if this is a block from a fundamentally distinct blockchain or a } => {
// continuation of this blockchain we have yet to sync (which would be `Future`) if !sp_core::sr25519::Signature::from(*signature).verify(
Err(TransactionValidityError::Unknown(UnknownTransaction::CannotLookup))?; Transaction::signature_message(calls, Context::implicit_context(), explicit_context)
} .as_slice(),
if let Some(include_by) = *include_by { &sp_core::sr25519::Public::from(explicit_context.signer),
if let Some(current_time) = context.current_time() { ) {
if current_time >= u64::from(include_by) { Err(sp_runtime::transaction_validity::InvalidTransaction::BadProof)?;
// Since this transaction has a time bound which has passed, error }
let ExplicitContext { historic_block, include_by, signer, nonce, fee } =
&explicit_context;
if !context.block_is_present_in_blockchain(historic_block) {
// We don't know if this is a block from a fundamentally distinct blockchain or a
// continuation of this blockchain we have yet to sync (which would be `Future`)
Err(TransactionValidityError::Unknown(UnknownTransaction::CannotLookup))?;
}
if let Some(include_by) = *include_by {
if let Some(current_time) = context.current_time() {
if current_time >= u64::from(include_by) {
// Since this transaction has a time bound which has passed, error
Err(TransactionValidityError::Invalid(InvalidTransaction::Stale))?;
}
} else {
// Since this transaction has a time bound, yet we don't know the time, error
Err(TransactionValidityError::Invalid(InvalidTransaction::Stale))?; Err(TransactionValidityError::Invalid(InvalidTransaction::Stale))?;
} }
} else {
// Since this transaction has a time bound, yet we don't know the time, error
Err(TransactionValidityError::Invalid(InvalidTransaction::Stale))?;
} }
} match context.next_nonce(signer).cmp(nonce) {
match context.next_nonce(signer).cmp(nonce) { core::cmp::Ordering::Less => {
core::cmp::Ordering::Less => { Err(TransactionValidityError::Invalid(InvalidTransaction::Stale))?
Err(TransactionValidityError::Invalid(InvalidTransaction::Stale))? }
} core::cmp::Ordering::Equal => {}
core::cmp::Ordering::Equal => {} core::cmp::Ordering::Greater => {
core::cmp::Ordering::Greater => { Err(TransactionValidityError::Invalid(InvalidTransaction::Future))?
Err(TransactionValidityError::Invalid(InvalidTransaction::Future))? }
} }
context.pay_fee(signer, *fee)?;
} }
if !sp_core::sr25519::Signature::from(*signature).verify(
Transaction::signature_message_unchecked(
&self.calls,
Context::implicit_context(),
explicit_context,
)
.as_slice(),
&sp_core::sr25519::Public::from(*signer),
) {
Err(sp_runtime::transaction_validity::InvalidTransaction::BadProof)?;
}
context.pay_fee(signer, *fee)?;
} }
Ok(TransactionWithContext(self, PhantomData)) Ok(TransactionWithContext(self, PhantomData))
@@ -397,81 +394,84 @@ mod substrate {
info: &DispatchInfo, info: &DispatchInfo,
_len: usize, _len: usize,
) -> sp_runtime::transaction_validity::TransactionValidity { ) -> sp_runtime::transaction_validity::TransactionValidity {
if !self.0.is_signed() { match &self.0 {
let ValidTransaction { priority: _, requires, provides, longevity: _, propagate: _ } = Transaction::Unsigned { call } => {
V::validate_unsigned(source, &Context::RuntimeCall::from(self.0.calls[0].clone()))?; let ValidTransaction { priority: _, requires, provides, longevity: _, propagate: _ } =
Ok(ValidTransaction { V::validate_unsigned(source, &Context::RuntimeCall::from(call.0.clone()))?;
// We should always try to include unsigned transactions prior to signed Ok(ValidTransaction {
priority: u64::MAX, // We should always try to include unsigned transactions prior to signed
requires, priority: u64::MAX,
provides, requires,
// This is valid until included provides,
longevity: u64::MAX, // This is valid until included
// Ensure this is propagated longevity: u64::MAX,
propagate: true, // Ensure this is propagated
}) propagate: true,
} else { })
let explicit_context = &self.0.contextualized_signature.as_ref().unwrap().explicit_context; }
let requires = if let Some(prior_nonce) = explicit_context.nonce.checked_sub(1) { Transaction::Signed { calls: _, contextualized_signature } => {
vec![borsh::to_vec(&(explicit_context.signer, prior_nonce)).unwrap()] let explicit_context = &contextualized_signature.explicit_context;
} else { let requires = if let Some(prior_nonce) = explicit_context.nonce.checked_sub(1) {
vec![] vec![borsh::to_vec(&(explicit_context.signer, prior_nonce)).unwrap()]
}; } else {
let provides = vec![]
vec![borsh::to_vec(&(explicit_context.signer, explicit_context.nonce)).unwrap()]; };
Ok(ValidTransaction { let provides =
// Prioritize transactions by their fees vec![borsh::to_vec(&(explicit_context.signer, explicit_context.nonce)).unwrap()];
priority: { Ok(ValidTransaction {
let fee = explicit_context.fee.0; // Prioritize transactions by their fees
Weight::from_all(fee).checked_div_per_component(&info.call_weight).unwrap_or(0) priority: {
}, let fee = explicit_context.fee.0;
requires, Weight::from_all(fee).checked_div_per_component(&info.call_weight).unwrap_or(0)
provides, },
// This revalidates the transaction every block. This is required due to this being requires,
// denominated in blocks, and our transaction expiration being denominated in seconds. provides,
longevity: 1, // This revalidates the transaction every block. This is required due to this being
propagate: true, // denominated in blocks, and our transaction expiration being denominated in seconds.
}) longevity: 1,
propagate: true,
})
}
} }
} }
fn apply<V: ValidateUnsigned<Call = Context::RuntimeCall>>( fn apply<V: ValidateUnsigned<Call = Context::RuntimeCall>>(
mut self, self,
_info: &DispatchInfo, _info: &DispatchInfo,
_len: usize, _len: usize,
) -> sp_runtime::ApplyExtrinsicResultWithInfo<PostDispatchInfo> { ) -> sp_runtime::ApplyExtrinsicResultWithInfo<PostDispatchInfo> {
if !self.0.is_signed() { match self.0 {
let call = Context::RuntimeCall::from(self.0.calls.remove(0)); Transaction::Unsigned { call } => {
V::pre_dispatch(&call)?; let call = Context::RuntimeCall::from(call.0);
match call.dispatch(None.into()) { V::pre_dispatch(&call)?;
Ok(res) => Ok(Ok(res)), match call.dispatch(None.into()) {
// Unsigned transactions should only be included if valid in all regards Ok(res) => Ok(Ok(res)),
// This isn't actually a "mandatory" but the intent is the same // Unsigned transactions should only be included if valid in all regards
Err(_err) => Err(TransactionValidityError::Invalid(InvalidTransaction::BadMandatory)), // This isn't actually a "mandatory" but the intent is the same
} Err(_err) => Err(TransactionValidityError::Invalid(InvalidTransaction::BadMandatory)),
} else {
Ok(frame_support::storage::transactional::with_storage_layer(|| {
for call in self.0.calls {
let call = Context::RuntimeCall::from(call);
match call.dispatch(
Some(self.0.contextualized_signature.as_ref().unwrap().explicit_context.signer)
.into(),
) {
Ok(_res) => {}
// Because this call errored, don't continue and revert all prior calls
Err(e) => Err(e)?,
}
} }
// Since all calls errored, return all }
Ok(PostDispatchInfo { Transaction::Signed { calls, contextualized_signature } => {
// `None` stands for the worst case, which is what we want Ok(frame_support::storage::transactional::with_storage_layer(|| {
actual_weight: None, for call in calls.0 {
// Signed transactions always pay their fee let call = Context::RuntimeCall::from(call);
// TODO: Do we want to handle this so we can not charge fees on removing genesis match call.dispatch(Some(contextualized_signature.explicit_context.signer).into()) {
// liquidity? Ok(_res) => {}
pays_fee: Pays::Yes, // Because this call errored, don't continue and revert all prior calls
}) Err(e) => Err(e)?,
})) }
}
// Since all calls errored, return all
Ok(PostDispatchInfo {
// `None` stands for the worst case, which is what we want
actual_weight: None,
// Signed transactions always pay their fee
// TODO: Do we want to handle this so we can not charge fees on removing genesis
// liquidity?
pays_fee: Pays::Yes,
})
}))
}
} }
} }
} }