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:
parent
1f4c0ba9cc
commit
7202b9339e
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -2699,6 +2699,7 @@ dependencies = [
|
|||
"borsh",
|
||||
"p2w-sdk",
|
||||
"pyth-client",
|
||||
"pyth-sdk-solana 0.5.0",
|
||||
"rocksalt",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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" }
|
||||
|
|
|
@ -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_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::<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
|
||||
|
|
|
@ -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,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;
|
||||
|
|
|
@ -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(),
|
||||
|
|
Loading…
Reference in New Issue