diff --git a/third_party/pyth/p2w-sdk/rust/src/lib.rs b/third_party/pyth/p2w-sdk/rust/src/lib.rs index ba5616f3..1cd8d1ed 100644 --- a/third_party/pyth/p2w-sdk/rust/src/lib.rs +++ b/third_party/pyth/p2w-sdk/rust/src/lib.rs @@ -12,9 +12,12 @@ pub use pyth_sdk::{ UnixTimestamp, }; #[cfg(feature = "solana")] -use solitaire::{ - Derive, - Info, +use { + pyth_sdk_solana::state::PriceAccount, + solitaire::{ + Derive, + Info, + }, }; use { serde::{ @@ -47,12 +50,18 @@ pub const P2W_MAGIC: &[u8] = b"P2WH"; /// Format version used and understood by this codebase pub const P2W_FORMAT_VER_MAJOR: u16 = 3; -/// Starting with v3, format introduces a minor version to mark forward-compatible iterations -pub const P2W_FORMAT_VER_MINOR: u16 = 0; +/// Starting with v3, format introduces a minor version to mark +/// 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 /// 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 PUBKEY_LEN: usize = 32; @@ -80,28 +89,29 @@ pub enum PayloadId { #[serde(rename_all = "camelCase")] pub struct PriceAttestation { #[serde(serialize_with = "pubkey_to_hex")] - pub product_id: Identifier, + pub product_id: Identifier, #[serde(serialize_with = "pubkey_to_hex")] - pub price_id: Identifier, + pub price_id: Identifier, #[serde(serialize_with = "use_to_string")] - pub price: i64, + pub price: i64, #[serde(serialize_with = "use_to_string")] - pub conf: u64, - pub expo: i32, + pub conf: u64, + pub expo: i32, #[serde(serialize_with = "use_to_string")] - pub ema_price: i64, + pub ema_price: i64, #[serde(serialize_with = "use_to_string")] - pub ema_conf: u64, - pub status: PriceStatus, - pub num_publishers: u32, - pub max_num_publishers: u32, - pub attestation_time: UnixTimestamp, - pub publish_time: UnixTimestamp, - pub prev_publish_time: UnixTimestamp, + pub ema_conf: u64, + pub status: PriceStatus, + pub num_publishers: u32, + pub max_num_publishers: u32, + pub attestation_time: UnixTimestamp, + pub publish_time: UnixTimestamp, + pub prev_publish_time: UnixTimestamp, #[serde(serialize_with = "use_to_string")] - pub prev_price: i64, + pub prev_price: i64, #[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 @@ -146,6 +156,10 @@ impl BatchPriceAttestation { // payload_id 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 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( price_id: Identifier, attestation_time: UnixTimestamp, + last_attested_publish_time: UnixTimestamp, value: &[u8], ) -> Result { - let price = pyth_sdk_solana::state::load_price_account(value)?; - - Ok(PriceAttestation { + let price_struct = pyth_sdk_solana::state::load_price_account(value)?; + Ok(Self::from_pyth_price_struct( + 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), price_id, price: price.agg.price, @@ -299,7 +327,8 @@ impl PriceAttestation { prev_publish_time: price.prev_timestamp, prev_price: price.prev_price, prev_conf: price.prev_conf, - }) + last_attested_publish_time, + } } /// Serialize this attestation according to the Pyth-over-wormhole serialization format @@ -322,6 +351,7 @@ impl PriceAttestation { prev_publish_time, prev_price, prev_conf, + last_attested_publish_time, } = self; let mut buf = Vec::new(); @@ -371,6 +401,9 @@ impl PriceAttestation { // prev_conf 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 } pub fn deserialize(mut bytes: impl Read) -> Result { @@ -444,6 +477,11 @@ impl PriceAttestation { bytes.read_exact(prev_conf_vec.as_mut_slice())?; 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::()]; + 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 { product_id, price_id, @@ -460,6 +498,7 @@ impl PriceAttestation { prev_publish_time, prev_price, prev_conf, + last_attested_publish_time, }) } } @@ -478,21 +517,22 @@ mod tests { let product_id_bytes = prod.unwrap_or([21u8; 32]); let price_id_bytes = price.unwrap_or([222u8; 32]); PriceAttestation { - product_id: Identifier::new(product_id_bytes), - price_id: Identifier::new(price_id_bytes), - price: 0x2bad2feed7, - conf: 101, - ema_price: -42, - ema_conf: 42, - expo: -3, - status: PriceStatus::Trading, - num_publishers: 123212u32, - max_num_publishers: 321232u32, - attestation_time: (0xdeadbeeffadedeedu64) as i64, - publish_time: 0xdadebeefi64, - prev_publish_time: 0xdeadbabei64, - prev_price: 0xdeadfacebeefi64, - prev_conf: 0xbadbadbeefu64, // I could do this all day -SD + product_id: Identifier::new(product_id_bytes), + price_id: Identifier::new(price_id_bytes), + price: 0x2bad2feed7, + conf: 101, + ema_price: -42, + ema_conf: 42, + expo: -3, + status: PriceStatus::Trading, + num_publishers: 123212u32, + max_num_publishers: 321232u32, + attestation_time: (0xdeadbeeffadedeedu64) as i64, + publish_time: 0xdadebeefi64, + prev_publish_time: 0xdeadbabei64, + prev_price: 0xdeadfacebeefi64, + prev_conf: 0xbadbadbeefu64, // I could do this all day -SD + last_attested_publish_time: (0xdeadbeeffadedeafu64) as i64, } } diff --git a/wormhole-attester/Cargo.lock b/wormhole-attester/Cargo.lock index 3ec78af4..ecc0b19d 100644 --- a/wormhole-attester/Cargo.lock +++ b/wormhole-attester/Cargo.lock @@ -2699,6 +2699,7 @@ dependencies = [ "borsh", "p2w-sdk", "pyth-client", + "pyth-sdk-solana 0.5.0", "rocksalt", "serde", "serde_derive", diff --git a/wormhole-attester/client/src/lib.rs b/wormhole-attester/client/src/lib.rs index 70570759..52c72cb9 100644 --- a/wormhole-attester/client/src/lib.rs +++ b/wormhole-attester/client/src/lib.rs @@ -49,6 +49,7 @@ use { load_product_account, }, pyth_wormhole_attester::{ + attestation_state::AttestationStateMapPDA, config::{ OldP2WConfigAccount, P2WConfigAccount, @@ -324,6 +325,8 @@ pub fn gen_attest_tx( AccountMeta::new_readonly(system_program::id(), false), // config AccountMeta::new_readonly(p2w_config_addr, false), + // attestation_state + AccountMeta::new(AttestationStateMapPDA::key(None, &p2w_addr), false), ]; // Batch contents and padding if applicable diff --git a/wormhole-attester/program/Cargo.toml b/wormhole-attester/program/Cargo.toml index a409b4b0..d79da6ad 100644 --- a/wormhole-attester/program/Cargo.toml +++ b/wormhole-attester/program/Cargo.toml @@ -25,3 +25,4 @@ p2w-sdk = { path = "../../third_party/pyth/p2w-sdk/rust", features = ["solana"] serde = { version = "1", optional = true} serde_derive = { version = "1", optional = true} serde_json = { version = "1", optional = true} +pyth-sdk-solana = { version = "0.5.0" } diff --git a/wormhole-attester/program/src/attest.rs b/wormhole-attester/program/src/attest.rs index db43003e..fd9bba2b 100644 --- a/wormhole-attester/program/src/attest.rs +++ b/wormhole-attester/program/src/attest.rs @@ -1,5 +1,9 @@ use { crate::{ + attestation_state::{ + AttestationState, + AttestationStateMapPDA, + }, config::P2WConfigAccount, message::{ P2WMessage, @@ -20,6 +24,7 @@ use { P2WEmitter, PriceAttestation, }, + pyth_sdk_solana::state::PriceStatus, solana_program::{ clock::Clock, program::{ @@ -34,6 +39,7 @@ use { solitaire::{ trace, AccountState, + CreationLamports, ExecutionContext, FromAccounts, Info, @@ -60,9 +66,10 @@ pub const P2W_MAX_BATCH_SIZE: u16 = 5; #[derive(FromAccounts)] pub struct Attest<'b> { // Payer also used for wormhole - pub payer: Mut>>, - pub system_program: Info<'b>, - pub config: P2WConfigAccount<'b, { AccountState::Initialized }>, + pub payer: Mut>>, + pub system_program: Info<'b>, + pub config: P2WConfigAccount<'b, { AccountState::Initialized }>, + pub attestation_state: Mut>, // 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 @@ -152,6 +159,7 @@ pub fn attest(ctx: &ExecutionContext, accs: &mut Attest, data: AttestData) -> So return Err(ProgramError::InvalidAccountData.into()); } + // Make the specified prices iterable let price_pair_opts = [ 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)); } + 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()), - accs.clock.unix_timestamp, - &price.try_borrow_data()?, - ) - .map_err(|e| { - trace!(&e.to_string()); - ProgramError::InvalidAccountData - })?; + attestation_time, + state_entry.last_attested_trading_publish_time, // Used as last_attested_publish_time + price_struct, + ); + + + // 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 // 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"); + // 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::>() + .as_slice()]), + )?; + trace!("Attestation state init OK"); + } let bridge_config = BridgeData::try_from_slice(&accs.wh_bridge.try_borrow_mut_data()?)?.config; // Pay wormhole fee diff --git a/wormhole-attester/program/src/attestation_state.rs b/wormhole-attester/program/src/attestation_state.rs new file mode 100644 index 00000000..4d1a8d30 --- /dev/null +++ b/wormhole-attester/program/src/attestation_state.rs @@ -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, +} + + +impl Owned for AttestationStateMap { + fn owner(&self) -> AccountOwner { + AccountOwner::This + } +} + +pub type AttestationStateMapPDA<'b> = Derive< + Data<'b, AttestationStateMap, { AccountState::MaybeInitialized }>, + "p2w-attestation-state-v1", +>; diff --git a/wormhole-attester/program/src/lib.rs b/wormhole-attester/program/src/lib.rs index 81dc1991..1d949eb2 100644 --- a/wormhole-attester/program/src/lib.rs +++ b/wormhole-attester/program/src/lib.rs @@ -1,6 +1,7 @@ #![allow(incomplete_features)] #![feature(adt_const_params)] pub mod attest; +pub mod attestation_state; pub mod config; pub mod initialize; pub mod message; diff --git a/wormhole-attester/program/src/message.rs b/wormhole-attester/program/src/message.rs index b3abb8b4..b4a333a7 100644 --- a/wormhole-attester/program/src/message.rs +++ b/wormhole-attester/program/src/message.rs @@ -44,7 +44,7 @@ impl<'a> Seeded<&P2WMessageDrvData> for P2WMessage<'a> { // See the note at 2022-09-05 above. // Change the version in the literal whenever you change the // 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.batch_size.to_be_bytes().to_vec(), data.id.to_be_bytes().to_vec(),