654 lines
20 KiB
Rust
654 lines
20 KiB
Rust
//! Constants and values common to every p2w custom-serialized message.
|
|
//!
|
|
//! The format makes no attempt to provide human-readable symbol names
|
|
//! in favor of explicit product/price Solana account addresses
|
|
//! (IDs). This choice was made to disambiguate any symbols with
|
|
//! similar human-readable names and provide a failsafe for some of
|
|
//! the probable adversarial scenarios.
|
|
|
|
pub mod pyth_extensions;
|
|
|
|
use std::{
|
|
borrow::Borrow,
|
|
convert::{
|
|
TryFrom,
|
|
TryInto,
|
|
},
|
|
io::Read,
|
|
iter::Iterator,
|
|
mem,
|
|
};
|
|
|
|
use borsh::BorshSerialize;
|
|
use pyth_client::{
|
|
AccountType,
|
|
CorpAction,
|
|
Ema,
|
|
Price,
|
|
PriceStatus,
|
|
PriceType,
|
|
};
|
|
use solana_program::{
|
|
clock::UnixTimestamp,
|
|
program_error::ProgramError,
|
|
pubkey::Pubkey,
|
|
};
|
|
use solitaire::{
|
|
trace,
|
|
ErrBox,
|
|
Result as SoliResult,
|
|
SolitaireError,
|
|
};
|
|
|
|
use self::pyth_extensions::{
|
|
P2WCorpAction,
|
|
P2WEma,
|
|
P2WPriceStatus,
|
|
P2WPriceType,
|
|
};
|
|
|
|
/// Precedes every message implementing the p2w serialization format
|
|
pub const P2W_MAGIC: &'static [u8] = b"P2WH";
|
|
|
|
/// Format version used and understood by this codebase
|
|
pub const P2W_FORMAT_VERSION: u16 = 2;
|
|
|
|
pub const PUBKEY_LEN: usize = 32;
|
|
|
|
/// Decides the format of following bytes
|
|
#[repr(u8)]
|
|
pub enum PayloadId {
|
|
PriceAttestation = 1, // Not in use, currently batch attestations imply PriceAttestation messages inside
|
|
PriceBatchAttestation,
|
|
}
|
|
|
|
// On-chain data types
|
|
|
|
/// The main attestation data type.
|
|
///
|
|
/// Important: For maximum security, *both* product_id and price_id
|
|
/// should be used as storage keys for known attestations in target
|
|
/// chain logic.
|
|
#[derive(Clone, Default, Debug, Eq, PartialEq)]
|
|
#[cfg_attr(
|
|
feature = "wasm",
|
|
derive(serde_derive::Serialize, serde_derive::Deserialize)
|
|
)]
|
|
pub struct PriceAttestation {
|
|
pub product_id: Pubkey,
|
|
pub price_id: Pubkey,
|
|
pub price_type: P2WPriceType,
|
|
pub price: i64,
|
|
pub expo: i32,
|
|
pub twap: P2WEma,
|
|
pub twac: P2WEma,
|
|
pub confidence_interval: u64,
|
|
pub status: P2WPriceStatus,
|
|
pub corp_act: P2WCorpAction,
|
|
pub timestamp: UnixTimestamp,
|
|
}
|
|
|
|
/// Turn a bunch of attestations into a combined payload.
|
|
///
|
|
/// Batches assume constant-size attestations within a single batch.
|
|
pub fn batch_serialize(
|
|
attestations: impl Iterator<Item = impl Borrow<PriceAttestation>>,
|
|
) -> Result<Vec<u8>, ErrBox> {
|
|
// magic
|
|
let mut buf = P2W_MAGIC.to_vec();
|
|
|
|
// version
|
|
buf.extend_from_slice(&P2W_FORMAT_VERSION.to_be_bytes()[..]);
|
|
|
|
// payload_id
|
|
buf.push(PayloadId::PriceBatchAttestation as u8);
|
|
|
|
let collected: Vec<_> = attestations.collect();
|
|
|
|
// n_attestations
|
|
buf.extend_from_slice(&(collected.len() as u16).to_be_bytes()[..]);
|
|
|
|
let mut attestation_size = 0; // Will be determined as we serialize attestations
|
|
let mut serialized_attestations = Vec::with_capacity(collected.len());
|
|
for (idx, a) in collected.iter().enumerate() {
|
|
// Learn the current attestation's size
|
|
let serialized = PriceAttestation::serialize(a.borrow());
|
|
let a_len = serialized.len();
|
|
|
|
// Verify it's the same as the first one we saw for the batch, assign if we're first.
|
|
if attestation_size > 0 {
|
|
if a_len != attestation_size {
|
|
return Err(format!(
|
|
"attestation {} serializes to {} bytes, {} expected",
|
|
idx + 1,
|
|
a_len,
|
|
attestation_size
|
|
)
|
|
.into());
|
|
}
|
|
} else {
|
|
attestation_size = a_len;
|
|
}
|
|
|
|
serialized_attestations.push(serialized);
|
|
}
|
|
|
|
// attestation_size
|
|
buf.extend_from_slice(&(attestation_size as u16).to_be_bytes()[..]);
|
|
|
|
for mut s in serialized_attestations.into_iter() {
|
|
buf.append(&mut s)
|
|
}
|
|
|
|
Ok(buf)
|
|
}
|
|
|
|
/// Undo `batch_serialize`
|
|
pub fn batch_deserialize(mut bytes: impl Read) -> Result<Vec<PriceAttestation>, ErrBox> {
|
|
let mut magic_vec = vec![0u8; P2W_MAGIC.len()];
|
|
bytes.read_exact(magic_vec.as_mut_slice())?;
|
|
|
|
if magic_vec.as_slice() != P2W_MAGIC {
|
|
return Err(format!(
|
|
"Invalid magic {:02X?}, expected {:02X?}",
|
|
magic_vec, P2W_MAGIC,
|
|
)
|
|
.into());
|
|
}
|
|
|
|
let mut version_vec = vec![0u8; mem::size_of_val(&P2W_FORMAT_VERSION)];
|
|
bytes.read_exact(version_vec.as_mut_slice())?;
|
|
let version = u16::from_be_bytes(version_vec.as_slice().try_into()?);
|
|
|
|
if version != P2W_FORMAT_VERSION {
|
|
return Err(format!(
|
|
"Unsupported format version {}, expected {}",
|
|
version, P2W_FORMAT_VERSION
|
|
)
|
|
.into());
|
|
}
|
|
|
|
let mut payload_id_vec = vec![0u8; mem::size_of::<PayloadId>()];
|
|
bytes.read_exact(payload_id_vec.as_mut_slice())?;
|
|
|
|
if payload_id_vec[0] != PayloadId::PriceBatchAttestation as u8 {
|
|
return Err(format!(
|
|
"Invalid Payload ID {}, expected {}",
|
|
payload_id_vec[0],
|
|
PayloadId::PriceBatchAttestation as u8,
|
|
)
|
|
.into());
|
|
}
|
|
|
|
let mut batch_len_vec = vec![0u8; 2];
|
|
bytes.read_exact(batch_len_vec.as_mut_slice())?;
|
|
let batch_len = u16::from_be_bytes(batch_len_vec.as_slice().try_into()?);
|
|
|
|
let mut attestation_size_vec = vec![0u8; 2];
|
|
bytes.read_exact(attestation_size_vec.as_mut_slice())?;
|
|
let attestation_size = u16::from_be_bytes(attestation_size_vec.as_slice().try_into()?);
|
|
|
|
let mut ret = Vec::with_capacity(batch_len as usize);
|
|
|
|
for i in 0..batch_len {
|
|
let mut attestation_buf = vec![0u8; attestation_size as usize];
|
|
bytes.read_exact(attestation_buf.as_mut_slice())?;
|
|
|
|
dbg!(&attestation_buf.len());
|
|
|
|
match PriceAttestation::deserialize(attestation_buf.as_slice()) {
|
|
Ok(attestation) => ret.push(attestation),
|
|
Err(e) => return Err(format!("PriceAttestation {}/{}: {}", i + 1, batch_len, e).into()),
|
|
}
|
|
}
|
|
|
|
Ok(ret)
|
|
}
|
|
|
|
impl PriceAttestation {
|
|
pub fn from_pyth_price_bytes(
|
|
price_id: Pubkey,
|
|
timestamp: UnixTimestamp,
|
|
value: &[u8],
|
|
) -> Result<Self, SolitaireError> {
|
|
let price = parse_pyth_price(value)?;
|
|
|
|
Ok(PriceAttestation {
|
|
product_id: Pubkey::new(&price.prod.val[..]),
|
|
price_id,
|
|
price_type: (&price.ptype).into(),
|
|
price: price.agg.price,
|
|
twap: (&price.twap).into(),
|
|
twac: (&price.twac).into(),
|
|
expo: price.expo,
|
|
confidence_interval: price.agg.conf,
|
|
status: (&price.agg.status).into(),
|
|
corp_act: (&price.agg.corp_act).into(),
|
|
timestamp: timestamp,
|
|
})
|
|
}
|
|
|
|
/// Serialize this attestation according to the Pyth-over-wormhole serialization format
|
|
pub fn serialize(&self) -> Vec<u8> {
|
|
// A nifty trick to get us yelled at if we forget to serialize a field
|
|
#[deny(warnings)]
|
|
let PriceAttestation {
|
|
product_id,
|
|
price_id,
|
|
price_type,
|
|
price,
|
|
expo,
|
|
twap,
|
|
twac,
|
|
confidence_interval,
|
|
status,
|
|
corp_act,
|
|
timestamp,
|
|
} = self;
|
|
|
|
// magic
|
|
let mut buf = P2W_MAGIC.to_vec();
|
|
|
|
// version
|
|
buf.extend_from_slice(&P2W_FORMAT_VERSION.to_be_bytes()[..]);
|
|
|
|
// payload_id
|
|
buf.push(PayloadId::PriceAttestation as u8);
|
|
|
|
// product_id
|
|
buf.extend_from_slice(&product_id.to_bytes()[..]);
|
|
|
|
// price_id
|
|
buf.extend_from_slice(&price_id.to_bytes()[..]);
|
|
|
|
// price_type
|
|
buf.push(price_type.clone() as u8);
|
|
|
|
// price
|
|
buf.extend_from_slice(&price.to_be_bytes()[..]);
|
|
|
|
// exponent
|
|
buf.extend_from_slice(&expo.to_be_bytes()[..]);
|
|
|
|
// twap
|
|
buf.append(&mut twap.serialize());
|
|
|
|
// twac
|
|
buf.append(&mut twac.serialize());
|
|
|
|
// confidence_interval
|
|
buf.extend_from_slice(&confidence_interval.to_be_bytes()[..]);
|
|
|
|
// status
|
|
buf.push(status.clone() as u8);
|
|
|
|
// corp_act
|
|
buf.push(corp_act.clone() as u8);
|
|
|
|
// timestamp
|
|
buf.extend_from_slice(×tamp.to_be_bytes()[..]);
|
|
|
|
buf
|
|
}
|
|
pub fn deserialize(mut bytes: impl Read) -> Result<Self, ErrBox> {
|
|
use P2WCorpAction::*;
|
|
use P2WPriceStatus::*;
|
|
use P2WPriceType::*;
|
|
|
|
let mut magic_vec = vec![0u8; P2W_MAGIC.len()];
|
|
|
|
bytes.read_exact(magic_vec.as_mut_slice())?;
|
|
|
|
if magic_vec.as_slice() != P2W_MAGIC {
|
|
return Err(format!(
|
|
"Invalid magic {:02X?}, expected {:02X?}",
|
|
magic_vec, P2W_MAGIC,
|
|
)
|
|
.into());
|
|
}
|
|
|
|
let mut version_vec = vec![0u8; mem::size_of_val(&P2W_FORMAT_VERSION)];
|
|
bytes.read_exact(version_vec.as_mut_slice())?;
|
|
let version = u16::from_be_bytes(version_vec.as_slice().try_into()?);
|
|
|
|
if version != P2W_FORMAT_VERSION {
|
|
return Err(format!(
|
|
"Unsupported format version {}, expected {}",
|
|
version, P2W_FORMAT_VERSION
|
|
)
|
|
.into());
|
|
}
|
|
|
|
let mut payload_id_vec = vec![0u8; mem::size_of::<PayloadId>()];
|
|
bytes.read_exact(payload_id_vec.as_mut_slice())?;
|
|
|
|
if PayloadId::PriceAttestation as u8 != payload_id_vec[0] {
|
|
return Err(format!(
|
|
"Invalid Payload ID {}, expected {}",
|
|
payload_id_vec[0],
|
|
PayloadId::PriceAttestation as u8,
|
|
)
|
|
.into());
|
|
}
|
|
|
|
let mut product_id_vec = vec![0u8; PUBKEY_LEN];
|
|
bytes.read_exact(product_id_vec.as_mut_slice())?;
|
|
let product_id = Pubkey::new(product_id_vec.as_slice());
|
|
|
|
let mut price_id_vec = vec![0u8; PUBKEY_LEN];
|
|
bytes.read_exact(price_id_vec.as_mut_slice())?;
|
|
let price_id = Pubkey::new(price_id_vec.as_slice());
|
|
|
|
let mut price_type_vec = vec![0u8; mem::size_of::<P2WPriceType>()];
|
|
bytes.read_exact(price_type_vec.as_mut_slice())?;
|
|
let price_type = match price_type_vec[0] {
|
|
a if a == Price as u8 => Price,
|
|
a if a == P2WPriceType::Unknown as u8 => P2WPriceType::Unknown,
|
|
other => {
|
|
return Err(format!("Invalid price_type value {}", other).into());
|
|
}
|
|
};
|
|
|
|
let mut price_vec = vec![0u8; mem::size_of::<i64>()];
|
|
bytes.read_exact(price_vec.as_mut_slice())?;
|
|
let price = i64::from_be_bytes(price_vec.as_slice().try_into()?);
|
|
|
|
let mut expo_vec = vec![0u8; mem::size_of::<i32>()];
|
|
bytes.read_exact(expo_vec.as_mut_slice())?;
|
|
let expo = i32::from_be_bytes(expo_vec.as_slice().try_into()?);
|
|
|
|
let twap = P2WEma::deserialize(&mut bytes)?;
|
|
let twac = P2WEma::deserialize(&mut bytes)?;
|
|
|
|
println!("twac OK");
|
|
let mut confidence_interval_vec = vec![0u8; mem::size_of::<u64>()];
|
|
bytes.read_exact(confidence_interval_vec.as_mut_slice())?;
|
|
let confidence_interval =
|
|
u64::from_be_bytes(confidence_interval_vec.as_slice().try_into()?);
|
|
|
|
let mut status_vec = vec![0u8; mem::size_of::<P2WPriceType>()];
|
|
bytes.read_exact(status_vec.as_mut_slice())?;
|
|
let status = match status_vec[0] {
|
|
a if a == P2WPriceStatus::Unknown as u8 => P2WPriceStatus::Unknown,
|
|
a if a == Trading as u8 => Trading,
|
|
a if a == Halted as u8 => Halted,
|
|
a if a == Auction as u8 => Auction,
|
|
other => {
|
|
return Err(format!("Invalid status value {}", other).into());
|
|
}
|
|
};
|
|
|
|
let mut corp_act_vec = vec![0u8; mem::size_of::<P2WPriceType>()];
|
|
bytes.read_exact(corp_act_vec.as_mut_slice())?;
|
|
let corp_act = match corp_act_vec[0] {
|
|
a if a == NoCorpAct as u8 => NoCorpAct,
|
|
other => {
|
|
return Err(format!("Invalid corp_act value {}", other).into());
|
|
}
|
|
};
|
|
|
|
let mut timestamp_vec = vec![0u8; mem::size_of::<UnixTimestamp>()];
|
|
bytes.read_exact(timestamp_vec.as_mut_slice())?;
|
|
let timestamp = UnixTimestamp::from_be_bytes(timestamp_vec.as_slice().try_into()?);
|
|
|
|
Ok(Self {
|
|
product_id,
|
|
price_id,
|
|
price_type,
|
|
price,
|
|
expo,
|
|
twap,
|
|
twac,
|
|
confidence_interval,
|
|
status,
|
|
corp_act,
|
|
timestamp,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Deserializes Price from raw bytes, sanity-check.
|
|
fn parse_pyth_price(price_data: &[u8]) -> SoliResult<&Price> {
|
|
if price_data.len() != mem::size_of::<Price>() {
|
|
trace!(&format!(
|
|
"parse_pyth_price: buffer length mismatch ({} expected, got {})",
|
|
mem::size_of::<Price>(),
|
|
price_data.len()
|
|
));
|
|
return Err(ProgramError::InvalidAccountData.into());
|
|
}
|
|
let price_account = pyth_client::cast::<Price>(price_data);
|
|
|
|
if price_account.atype != AccountType::Price as u32 {
|
|
trace!(&format!(
|
|
"parse_pyth_price: AccountType mismatch ({} expected, got {})",
|
|
mem::size_of::<Price>(),
|
|
price_data.len()
|
|
));
|
|
return Err(ProgramError::InvalidAccountData.into());
|
|
}
|
|
|
|
Ok(price_account)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use pyth_client::{
|
|
AccKey,
|
|
AccountType,
|
|
PriceComp,
|
|
PriceInfo,
|
|
};
|
|
|
|
macro_rules! empty_acckey {
|
|
() => {
|
|
AccKey { val: [0u8; 32] }
|
|
};
|
|
}
|
|
|
|
macro_rules! empty_priceinfo {
|
|
() => {
|
|
PriceInfo {
|
|
price: 0,
|
|
conf: 0,
|
|
status: PriceStatus::Unknown,
|
|
corp_act: CorpAction::NoCorpAct,
|
|
pub_slot: 0,
|
|
}
|
|
};
|
|
}
|
|
|
|
macro_rules! empty_pricecomp {
|
|
() => {
|
|
PriceComp {
|
|
publisher: empty_acckey!(),
|
|
agg: empty_priceinfo!(),
|
|
latest: empty_priceinfo!(),
|
|
}
|
|
};
|
|
}
|
|
|
|
macro_rules! empty_ema {
|
|
() => {
|
|
(&P2WEma::default()).into()
|
|
};
|
|
}
|
|
|
|
macro_rules! empty_price {
|
|
() => {
|
|
Price {
|
|
magic: pyth_client::MAGIC,
|
|
ver: pyth_client::VERSION,
|
|
atype: AccountType::Price as u32,
|
|
size: 0,
|
|
ptype: PriceType::Price,
|
|
expo: 0,
|
|
num: 0,
|
|
num_qt: 0,
|
|
last_slot: 0,
|
|
valid_slot: 0,
|
|
drv1: 0,
|
|
drv2: 0,
|
|
drv3: 0,
|
|
twap: empty_ema!(),
|
|
twac: empty_ema!(),
|
|
prod: empty_acckey!(),
|
|
next: empty_acckey!(),
|
|
prev_slot: 0, // valid slot of previous update
|
|
prev_price: 0, // aggregate price of previous update
|
|
prev_conf: 0, // confidence interval of previous update
|
|
agg: empty_priceinfo!(),
|
|
// A nice macro might come in handy if this gets annoying
|
|
comp: [
|
|
empty_pricecomp!(),
|
|
empty_pricecomp!(),
|
|
empty_pricecomp!(),
|
|
empty_pricecomp!(),
|
|
empty_pricecomp!(),
|
|
empty_pricecomp!(),
|
|
empty_pricecomp!(),
|
|
empty_pricecomp!(),
|
|
empty_pricecomp!(),
|
|
empty_pricecomp!(),
|
|
empty_pricecomp!(),
|
|
empty_pricecomp!(),
|
|
empty_pricecomp!(),
|
|
empty_pricecomp!(),
|
|
empty_pricecomp!(),
|
|
empty_pricecomp!(),
|
|
empty_pricecomp!(),
|
|
empty_pricecomp!(),
|
|
empty_pricecomp!(),
|
|
empty_pricecomp!(),
|
|
empty_pricecomp!(),
|
|
empty_pricecomp!(),
|
|
empty_pricecomp!(),
|
|
empty_pricecomp!(),
|
|
empty_pricecomp!(),
|
|
empty_pricecomp!(),
|
|
empty_pricecomp!(),
|
|
empty_pricecomp!(),
|
|
empty_pricecomp!(),
|
|
empty_pricecomp!(),
|
|
empty_pricecomp!(),
|
|
empty_pricecomp!(),
|
|
],
|
|
}
|
|
};
|
|
}
|
|
|
|
fn mock_attestation(prod: Option<[u8; 32]>, price: Option<[u8; 32]>) -> PriceAttestation {
|
|
let product_id_bytes = prod.unwrap_or([21u8; 32]);
|
|
let price_id_bytes = prod.unwrap_or([222u8; 32]);
|
|
PriceAttestation {
|
|
product_id: Pubkey::new_from_array(product_id_bytes),
|
|
price_id: Pubkey::new_from_array(price_id_bytes),
|
|
price: (0xdeadbeefdeadbabe as u64) as i64,
|
|
price_type: P2WPriceType::Price,
|
|
twap: P2WEma {
|
|
val: -42,
|
|
numer: 15,
|
|
denom: 37,
|
|
},
|
|
twac: P2WEma {
|
|
val: 42,
|
|
numer: 1111,
|
|
denom: 2222,
|
|
},
|
|
expo: -3,
|
|
status: P2WPriceStatus::Trading,
|
|
confidence_interval: 101,
|
|
corp_act: P2WCorpAction::NoCorpAct,
|
|
timestamp: 123456789i64,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_pyth_price_wrong_size_slices() {
|
|
assert!(parse_pyth_price(&[]).is_err());
|
|
assert!(parse_pyth_price(vec![0u8; 1].as_slice()).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_pyth_price() -> SoliResult<()> {
|
|
let price = Price {
|
|
expo: 5,
|
|
agg: PriceInfo {
|
|
price: 42,
|
|
..empty_priceinfo!()
|
|
},
|
|
..empty_price!()
|
|
};
|
|
let price_vec = vec![price];
|
|
|
|
// use the C repr to mock pyth's format
|
|
let (_, bytes, _) = unsafe { price_vec.as_slice().align_to::<u8>() };
|
|
|
|
parse_pyth_price(bytes)?;
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_attestation_serde() -> Result<(), ErrBox> {
|
|
let product_id_bytes = [21u8; 32];
|
|
let price_id_bytes = [222u8; 32];
|
|
let attestation: PriceAttestation =
|
|
mock_attestation(Some(product_id_bytes), Some(price_id_bytes));
|
|
|
|
println!("Hex product_id: {:02X?}", &product_id_bytes);
|
|
println!("Hex price_id: {:02X?}", &price_id_bytes);
|
|
|
|
println!("Regular: {:#?}", &attestation);
|
|
println!("Hex: {:#02X?}", &attestation);
|
|
let bytes = attestation.serialize();
|
|
println!("Hex Bytes: {:02X?}", bytes);
|
|
|
|
assert_eq!(
|
|
PriceAttestation::deserialize(bytes.as_slice())?,
|
|
attestation
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_attestation_serde_wrong_size() -> Result<(), ErrBox> {
|
|
assert!(PriceAttestation::deserialize(&[][..]).is_err());
|
|
assert!(PriceAttestation::deserialize(vec![0u8; 1].as_slice()).is_err());
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_batch_serde() -> Result<(), ErrBox> {
|
|
let attestations: Vec<_> = (0..65535)
|
|
.map(|i| mock_attestation(Some([(i % 256) as u8; 32]), None))
|
|
.collect();
|
|
|
|
let serialized = batch_serialize(attestations.iter())?;
|
|
|
|
let deserialized = batch_deserialize(serialized.as_slice())?;
|
|
|
|
assert_eq!(attestations, deserialized);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_batch_serde_wrong_size() -> Result<(), ErrBox> {
|
|
assert!(batch_deserialize(&[][..]).is_err());
|
|
assert!(batch_deserialize(vec![0u8; 1].as_slice()).is_err());
|
|
|
|
let attestations: Vec<_> = (0..20)
|
|
.map(|i| mock_attestation(Some([(i % 256) as u8; 32]), None))
|
|
.collect();
|
|
|
|
let serialized = batch_serialize(attestations.iter())?;
|
|
|
|
// Missing last byte in last attestation must be an error
|
|
let len = serialized.len();
|
|
assert!(batch_deserialize(&serialized.as_slice()[..len - 1]).is_err());
|
|
|
|
Ok(())
|
|
}
|
|
}
|