wormhole-attester: Add a previous attestation timestamp field (#488)

* wormhole-attester: Add a previous attestation timestamp field

This change bumps price batch format to v3.1 with a new backwards
compatible field - prev_attestation_time. This is the last time we've
successfully attested the price. If no prior record exists, the
current time is used (the same as attestation_time).

The new field is backed by a new PDA for the attester contract, called
'attestation state'. In this PDA, we store a Pubkey -> Metadata
hashmap for every price. Currently, the metadata stores just the
latest successful attestation timestamp for use with the new field.

* wormhole-attester: Use publish_time instead of attestation_time

* wormhole_attester: use prev_publish_time for non-trading prices
This commit is contained in:
Stanisław Drozd 2023-01-19 14:38:45 +01:00 committed by GitHub
parent 1f4c0ba9cc
commit 7202b9339e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 229 additions and 52 deletions

View File

@ -12,9 +12,12 @@ pub use pyth_sdk::{
UnixTimestamp, UnixTimestamp,
}; };
#[cfg(feature = "solana")] #[cfg(feature = "solana")]
use solitaire::{ use {
Derive, pyth_sdk_solana::state::PriceAccount,
Info, solitaire::{
Derive,
Info,
},
}; };
use { use {
serde::{ serde::{
@ -47,12 +50,18 @@ pub const P2W_MAGIC: &[u8] = b"P2WH";
/// Format version used and understood by this codebase /// Format version used and understood by this codebase
pub const P2W_FORMAT_VER_MAJOR: u16 = 3; pub const P2W_FORMAT_VER_MAJOR: u16 = 3;
/// Starting with v3, format introduces a minor version to mark forward-compatible iterations /// Starting with v3, format introduces a minor version to mark
pub const P2W_FORMAT_VER_MINOR: u16 = 0; /// forward-compatible iterations.
/// IMPORTANT: Remember to reset this to 0 whenever major version is
/// bumped.
/// Changelog:
/// * v3.1 - last_attested_publish_time field added
pub const P2W_FORMAT_VER_MINOR: u16 = 1;
/// Starting with v3, format introduces append-only /// Starting with v3, format introduces append-only
/// forward-compatibility to the header. This is the current number of /// forward-compatibility to the header. This is the current number of
/// bytes after the hdr_size field. /// bytes after the hdr_size field. After the specified bytes, inner
/// payload-specific fields begin.
pub const P2W_FORMAT_HDR_SIZE: u16 = 1; pub const P2W_FORMAT_HDR_SIZE: u16 = 1;
pub const PUBKEY_LEN: usize = 32; pub const PUBKEY_LEN: usize = 32;
@ -80,28 +89,29 @@ pub enum PayloadId {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct PriceAttestation { pub struct PriceAttestation {
#[serde(serialize_with = "pubkey_to_hex")] #[serde(serialize_with = "pubkey_to_hex")]
pub product_id: Identifier, pub product_id: Identifier,
#[serde(serialize_with = "pubkey_to_hex")] #[serde(serialize_with = "pubkey_to_hex")]
pub price_id: Identifier, pub price_id: Identifier,
#[serde(serialize_with = "use_to_string")] #[serde(serialize_with = "use_to_string")]
pub price: i64, pub price: i64,
#[serde(serialize_with = "use_to_string")] #[serde(serialize_with = "use_to_string")]
pub conf: u64, pub conf: u64,
pub expo: i32, pub expo: i32,
#[serde(serialize_with = "use_to_string")] #[serde(serialize_with = "use_to_string")]
pub ema_price: i64, pub ema_price: i64,
#[serde(serialize_with = "use_to_string")] #[serde(serialize_with = "use_to_string")]
pub ema_conf: u64, pub ema_conf: u64,
pub status: PriceStatus, pub status: PriceStatus,
pub num_publishers: u32, pub num_publishers: u32,
pub max_num_publishers: u32, pub max_num_publishers: u32,
pub attestation_time: UnixTimestamp, pub attestation_time: UnixTimestamp,
pub publish_time: UnixTimestamp, pub publish_time: UnixTimestamp,
pub prev_publish_time: UnixTimestamp, pub prev_publish_time: UnixTimestamp,
#[serde(serialize_with = "use_to_string")] #[serde(serialize_with = "use_to_string")]
pub prev_price: i64, pub prev_price: i64,
#[serde(serialize_with = "use_to_string")] #[serde(serialize_with = "use_to_string")]
pub prev_conf: u64, pub prev_conf: u64,
pub last_attested_publish_time: UnixTimestamp,
} }
/// Helper allowing ToString implementers to be serialized as strings accordingly /// Helper allowing ToString implementers to be serialized as strings accordingly
@ -146,6 +156,10 @@ impl BatchPriceAttestation {
// payload_id // payload_id
buf.push(PayloadId::PriceBatchAttestation as u8); buf.push(PayloadId::PriceBatchAttestation as u8);
// Header is over. NOTE: If you need to append to the header,
// make sure that the number of bytes after hdr_size is
// reflected in the P2W_FORMAT_HDR_SIZE constant.
// n_attestations // n_attestations
buf.extend_from_slice(&(self.price_attestations.len() as u16).to_be_bytes()[..]); buf.extend_from_slice(&(self.price_attestations.len() as u16).to_be_bytes()[..]);
@ -279,11 +293,25 @@ impl PriceAttestation {
pub fn from_pyth_price_bytes( pub fn from_pyth_price_bytes(
price_id: Identifier, price_id: Identifier,
attestation_time: UnixTimestamp, attestation_time: UnixTimestamp,
last_attested_publish_time: UnixTimestamp,
value: &[u8], value: &[u8],
) -> Result<Self, ErrBox> { ) -> Result<Self, ErrBox> {
let price = pyth_sdk_solana::state::load_price_account(value)?; let price_struct = pyth_sdk_solana::state::load_price_account(value)?;
Ok(Self::from_pyth_price_struct(
Ok(PriceAttestation { price_id,
attestation_time,
last_attested_publish_time,
price_struct,
))
}
#[cfg(feature = "solana")]
pub fn from_pyth_price_struct(
price_id: Identifier,
attestation_time: UnixTimestamp,
last_attested_publish_time: UnixTimestamp,
price: &PriceAccount,
) -> Self {
PriceAttestation {
product_id: Identifier::new(price.prod.val), product_id: Identifier::new(price.prod.val),
price_id, price_id,
price: price.agg.price, price: price.agg.price,
@ -299,7 +327,8 @@ impl PriceAttestation {
prev_publish_time: price.prev_timestamp, prev_publish_time: price.prev_timestamp,
prev_price: price.prev_price, prev_price: price.prev_price,
prev_conf: price.prev_conf, prev_conf: price.prev_conf,
}) last_attested_publish_time,
}
} }
/// Serialize this attestation according to the Pyth-over-wormhole serialization format /// Serialize this attestation according to the Pyth-over-wormhole serialization format
@ -322,6 +351,7 @@ impl PriceAttestation {
prev_publish_time, prev_publish_time,
prev_price, prev_price,
prev_conf, prev_conf,
last_attested_publish_time,
} = self; } = self;
let mut buf = Vec::new(); let mut buf = Vec::new();
@ -371,6 +401,9 @@ impl PriceAttestation {
// prev_conf // prev_conf
buf.extend_from_slice(&prev_conf.to_be_bytes()[..]); buf.extend_from_slice(&prev_conf.to_be_bytes()[..]);
// last_attested_publish_time
buf.extend_from_slice(&last_attested_publish_time.to_be_bytes()[..]);
buf buf
} }
pub fn deserialize(mut bytes: impl Read) -> Result<Self, ErrBox> { pub fn deserialize(mut bytes: impl Read) -> Result<Self, ErrBox> {
@ -444,6 +477,11 @@ impl PriceAttestation {
bytes.read_exact(prev_conf_vec.as_mut_slice())?; bytes.read_exact(prev_conf_vec.as_mut_slice())?;
let prev_conf = u64::from_be_bytes(prev_conf_vec.as_slice().try_into()?); let prev_conf = u64::from_be_bytes(prev_conf_vec.as_slice().try_into()?);
let mut last_attested_publish_time_vec = vec![0u8; mem::size_of::<UnixTimestamp>()];
bytes.read_exact(last_attested_publish_time_vec.as_mut_slice())?;
let last_attested_publish_time =
UnixTimestamp::from_be_bytes(last_attested_publish_time_vec.as_slice().try_into()?);
Ok(Self { Ok(Self {
product_id, product_id,
price_id, price_id,
@ -460,6 +498,7 @@ impl PriceAttestation {
prev_publish_time, prev_publish_time,
prev_price, prev_price,
prev_conf, prev_conf,
last_attested_publish_time,
}) })
} }
} }
@ -478,21 +517,22 @@ mod tests {
let product_id_bytes = prod.unwrap_or([21u8; 32]); let product_id_bytes = prod.unwrap_or([21u8; 32]);
let price_id_bytes = price.unwrap_or([222u8; 32]); let price_id_bytes = price.unwrap_or([222u8; 32]);
PriceAttestation { PriceAttestation {
product_id: Identifier::new(product_id_bytes), product_id: Identifier::new(product_id_bytes),
price_id: Identifier::new(price_id_bytes), price_id: Identifier::new(price_id_bytes),
price: 0x2bad2feed7, price: 0x2bad2feed7,
conf: 101, conf: 101,
ema_price: -42, ema_price: -42,
ema_conf: 42, ema_conf: 42,
expo: -3, expo: -3,
status: PriceStatus::Trading, status: PriceStatus::Trading,
num_publishers: 123212u32, num_publishers: 123212u32,
max_num_publishers: 321232u32, max_num_publishers: 321232u32,
attestation_time: (0xdeadbeeffadedeedu64) as i64, attestation_time: (0xdeadbeeffadedeedu64) as i64,
publish_time: 0xdadebeefi64, publish_time: 0xdadebeefi64,
prev_publish_time: 0xdeadbabei64, prev_publish_time: 0xdeadbabei64,
prev_price: 0xdeadfacebeefi64, prev_price: 0xdeadfacebeefi64,
prev_conf: 0xbadbadbeefu64, // I could do this all day -SD prev_conf: 0xbadbadbeefu64, // I could do this all day -SD
last_attested_publish_time: (0xdeadbeeffadedeafu64) as i64,
} }
} }

View File

@ -2699,6 +2699,7 @@ dependencies = [
"borsh", "borsh",
"p2w-sdk", "p2w-sdk",
"pyth-client", "pyth-client",
"pyth-sdk-solana 0.5.0",
"rocksalt", "rocksalt",
"serde", "serde",
"serde_derive", "serde_derive",

View File

@ -49,6 +49,7 @@ use {
load_product_account, load_product_account,
}, },
pyth_wormhole_attester::{ pyth_wormhole_attester::{
attestation_state::AttestationStateMapPDA,
config::{ config::{
OldP2WConfigAccount, OldP2WConfigAccount,
P2WConfigAccount, P2WConfigAccount,
@ -324,6 +325,8 @@ pub fn gen_attest_tx(
AccountMeta::new_readonly(system_program::id(), false), AccountMeta::new_readonly(system_program::id(), false),
// config // config
AccountMeta::new_readonly(p2w_config_addr, false), AccountMeta::new_readonly(p2w_config_addr, false),
// attestation_state
AccountMeta::new(AttestationStateMapPDA::key(None, &p2w_addr), false),
]; ];
// Batch contents and padding if applicable // Batch contents and padding if applicable

View File

@ -25,3 +25,4 @@ p2w-sdk = { path = "../../third_party/pyth/p2w-sdk/rust", features = ["solana"]
serde = { version = "1", optional = true} serde = { version = "1", optional = true}
serde_derive = { version = "1", optional = true} serde_derive = { version = "1", optional = true}
serde_json = { version = "1", optional = true} serde_json = { version = "1", optional = true}
pyth-sdk-solana = { version = "0.5.0" }

View File

@ -1,5 +1,9 @@
use { use {
crate::{ crate::{
attestation_state::{
AttestationState,
AttestationStateMapPDA,
},
config::P2WConfigAccount, config::P2WConfigAccount,
message::{ message::{
P2WMessage, P2WMessage,
@ -20,6 +24,7 @@ use {
P2WEmitter, P2WEmitter,
PriceAttestation, PriceAttestation,
}, },
pyth_sdk_solana::state::PriceStatus,
solana_program::{ solana_program::{
clock::Clock, clock::Clock,
program::{ program::{
@ -34,6 +39,7 @@ use {
solitaire::{ solitaire::{
trace, trace,
AccountState, AccountState,
CreationLamports,
ExecutionContext, ExecutionContext,
FromAccounts, FromAccounts,
Info, Info,
@ -60,9 +66,10 @@ pub const P2W_MAX_BATCH_SIZE: u16 = 5;
#[derive(FromAccounts)] #[derive(FromAccounts)]
pub struct Attest<'b> { pub struct Attest<'b> {
// Payer also used for wormhole // Payer also used for wormhole
pub payer: Mut<Signer<Info<'b>>>, pub payer: Mut<Signer<Info<'b>>>,
pub system_program: Info<'b>, pub system_program: Info<'b>,
pub config: P2WConfigAccount<'b, { AccountState::Initialized }>, pub config: P2WConfigAccount<'b, { AccountState::Initialized }>,
pub attestation_state: Mut<AttestationStateMapPDA<'b>>,
// Hardcoded product/price pairs, bypassing Solitaire's variable-length limitations // Hardcoded product/price pairs, bypassing Solitaire's variable-length limitations
// Any change to the number of accounts must include an appropriate change to P2W_MAX_BATCH_SIZE // Any change to the number of accounts must include an appropriate change to P2W_MAX_BATCH_SIZE
@ -152,6 +159,7 @@ pub fn attest(ctx: &ExecutionContext, accs: &mut Attest, data: AttestData) -> So
return Err(ProgramError::InvalidAccountData.into()); return Err(ProgramError::InvalidAccountData.into());
} }
// Make the specified prices iterable // Make the specified prices iterable
let price_pair_opts = [ let price_pair_opts = [
Some(&accs.pyth_product), Some(&accs.pyth_product),
@ -204,16 +212,48 @@ pub fn attest(ctx: &ExecutionContext, accs: &mut Attest, data: AttestData) -> So
)); ));
return Err(SolitaireError::InvalidOwner(*accs.pyth_price.owner)); return Err(SolitaireError::InvalidOwner(*accs.pyth_price.owner));
} }
let attestation_time = accs.clock.unix_timestamp;
let attestation = PriceAttestation::from_pyth_price_bytes( let price_data_ref = price.try_borrow_data()?;
// Parse the upstream Pyth struct to extract current publish
// time for payload construction
let price_struct =
pyth_sdk_solana::state::load_price_account(&price_data_ref).map_err(|e| {
trace!(&e.to_string());
ProgramError::InvalidAccountData
})?;
// prev_publish_time is picked if the price is not trading
let last_trading_publish_time = match price_struct.agg.status {
PriceStatus::Trading => price_struct.timestamp,
_ => price_struct.prev_timestamp,
};
// Take a mut reference to this price's metadata
let state_entry: &mut AttestationState = accs
.attestation_state
.entries
.entry(*price.key)
.or_insert(AttestationState {
// Use the same value if no state
// exists for the symbol, the new value _becomes_ the
// last attested trading publish time
last_attested_trading_publish_time: last_trading_publish_time,
});
let attestation = PriceAttestation::from_pyth_price_struct(
Identifier::new(price.key.to_bytes()), Identifier::new(price.key.to_bytes()),
accs.clock.unix_timestamp, attestation_time,
&price.try_borrow_data()?, state_entry.last_attested_trading_publish_time, // Used as last_attested_publish_time
) price_struct,
.map_err(|e| { );
trace!(&e.to_string());
ProgramError::InvalidAccountData
})?; // update last_attested_publish_time with this price's
// publish_time. Yes, it may be redundant for the entry() used
// above in the rare first attestation edge case.
state_entry.last_attested_trading_publish_time = last_trading_publish_time;
// The following check is crucial against poorly ordered // The following check is crucial against poorly ordered
// account inputs, e.g. [Some(prod1), Some(price1), // account inputs, e.g. [Some(prod1), Some(price1),
@ -240,6 +280,51 @@ pub fn attest(ctx: &ExecutionContext, accs: &mut Attest, data: AttestData) -> So
trace!("Attestations successfully created"); trace!("Attestations successfully created");
// Serialize the state to calculate rent/account size adjustments
let serialized = accs.attestation_state.1.try_to_vec()?;
if accs.attestation_state.is_initialized() {
accs.attestation_state
.info()
.realloc(serialized.len(), false)?;
trace!("Attestation state resize OK");
let target_rent = CreationLamports::Exempt.amount(serialized.len());
let current_rent = accs.attestation_state.info().lamports();
// Adjust rent, but only if there isn't enough
if target_rent > current_rent {
let transfer_amount = target_rent - current_rent;
let transfer_ix = system_instruction::transfer(
accs.payer.info().key,
accs.attestation_state.info().key,
transfer_amount,
);
invoke(&transfer_ix, ctx.accounts)?;
}
trace!("Attestation state rent transfer OK");
} else {
let seeds = accs
.attestation_state
.self_bumped_seeds(None, ctx.program_id);
solitaire::create_account(
ctx,
accs.attestation_state.info(),
accs.payer.key,
solitaire::CreationLamports::Exempt,
serialized.len(),
ctx.program_id,
solitaire::IsSigned::SignedWithSeeds(&[seeds
.iter()
.map(|s| s.as_slice())
.collect::<Vec<_>>()
.as_slice()]),
)?;
trace!("Attestation state init OK");
}
let bridge_config = BridgeData::try_from_slice(&accs.wh_bridge.try_borrow_mut_data()?)?.config; let bridge_config = BridgeData::try_from_slice(&accs.wh_bridge.try_borrow_mut_data()?)?.config;
// Pay wormhole fee // Pay wormhole fee

View File

@ -0,0 +1,46 @@
//! Implementation of per-symbol on-chain state. Currently used to
//! store latest successful attestation time for each price.
use {
borsh::{
BorshDeserialize,
BorshSerialize,
},
solana_program::{
clock::UnixTimestamp,
pubkey::Pubkey,
},
solitaire::{
AccountOwner,
AccountState,
Data,
Derive,
Owned,
},
std::collections::BTreeMap,
};
/// On-chain state for a single price attestation
#[derive(BorshSerialize, BorshDeserialize)]
pub struct AttestationState {
/// The last trading publish_time this attester saw
pub last_attested_trading_publish_time: UnixTimestamp,
}
/// Top-level state gathering all known AttestationState values, keyed by price address.
#[derive(BorshSerialize, BorshDeserialize, Default)]
pub struct AttestationStateMap {
pub entries: BTreeMap<Pubkey, AttestationState>,
}
impl Owned for AttestationStateMap {
fn owner(&self) -> AccountOwner {
AccountOwner::This
}
}
pub type AttestationStateMapPDA<'b> = Derive<
Data<'b, AttestationStateMap, { AccountState::MaybeInitialized }>,
"p2w-attestation-state-v1",
>;

View File

@ -1,6 +1,7 @@
#![allow(incomplete_features)] #![allow(incomplete_features)]
#![feature(adt_const_params)] #![feature(adt_const_params)]
pub mod attest; pub mod attest;
pub mod attestation_state;
pub mod config; pub mod config;
pub mod initialize; pub mod initialize;
pub mod message; pub mod message;

View File

@ -44,7 +44,7 @@ impl<'a> Seeded<&P2WMessageDrvData> for P2WMessage<'a> {
// See the note at 2022-09-05 above. // See the note at 2022-09-05 above.
// Change the version in the literal whenever you change the // Change the version in the literal whenever you change the
// price attestation data. // price attestation data.
"p2w-message-v1".as_bytes().to_vec(), "p2w-message-v2".as_bytes().to_vec(),
data.message_owner.to_bytes().to_vec(), data.message_owner.to_bytes().to_vec(),
data.batch_size.to_be_bytes().to_vec(), data.batch_size.to_be_bytes().to_vec(),
data.id.to_be_bytes().to_vec(), data.id.to_be_bytes().to_vec(),