Browse Source

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
Stanisław Drozd 2 years ago
parent
commit
7202b9339e

+ 80 - 40
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<Self, ErrBox> {
-        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<Self, ErrBox> {
@@ -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::<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 {
             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,
         }
     }
 

+ 1 - 0
wormhole-attester/Cargo.lock

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

+ 3 - 0
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

+ 1 - 0
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" }

+ 97 - 12
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<Signer<Info<'b>>>,
-    pub system_program: Info<'b>,
-    pub config:         P2WConfigAccount<'b, { AccountState::Initialized }>,
+    pub payer:             Mut<Signer<Info<'b>>>,
+    pub system_program:    Info<'b>,
+    pub config:            P2WConfigAccount<'b, { AccountState::Initialized }>,
+    pub attestation_state: Mut<AttestationStateMapPDA<'b>>,
 
     // 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 = PriceAttestation::from_pyth_price_bytes(
+        let attestation_time = accs.clock.unix_timestamp;
+
+        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::<Vec<_>>()
+                .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

+ 46 - 0
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<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",
+>;

+ 1 - 0
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;

+ 1 - 1
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(),