Skip to content

Commit 7202b93

Browse files
authored
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
1 parent 1f4c0ba commit 7202b93

File tree

8 files changed

+230
-53
lines changed

8 files changed

+230
-53
lines changed

third_party/pyth/p2w-sdk/rust/src/lib.rs

Lines changed: 80 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,12 @@ pub use pyth_sdk::{
1212
UnixTimestamp,
1313
};
1414
#[cfg(feature = "solana")]
15-
use solitaire::{
16-
Derive,
17-
Info,
15+
use {
16+
pyth_sdk_solana::state::PriceAccount,
17+
solitaire::{
18+
Derive,
19+
Info,
20+
},
1821
};
1922
use {
2023
serde::{
@@ -47,12 +50,18 @@ pub const P2W_MAGIC: &[u8] = b"P2WH";
4750
/// Format version used and understood by this codebase
4851
pub const P2W_FORMAT_VER_MAJOR: u16 = 3;
4952

50-
/// Starting with v3, format introduces a minor version to mark forward-compatible iterations
51-
pub const P2W_FORMAT_VER_MINOR: u16 = 0;
53+
/// Starting with v3, format introduces a minor version to mark
54+
/// forward-compatible iterations.
55+
/// IMPORTANT: Remember to reset this to 0 whenever major version is
56+
/// bumped.
57+
/// Changelog:
58+
/// * v3.1 - last_attested_publish_time field added
59+
pub const P2W_FORMAT_VER_MINOR: u16 = 1;
5260

5361
/// Starting with v3, format introduces append-only
5462
/// forward-compatibility to the header. This is the current number of
55-
/// bytes after the hdr_size field.
63+
/// bytes after the hdr_size field. After the specified bytes, inner
64+
/// payload-specific fields begin.
5665
pub const P2W_FORMAT_HDR_SIZE: u16 = 1;
5766

5867
pub const PUBKEY_LEN: usize = 32;
@@ -80,28 +89,29 @@ pub enum PayloadId {
8089
#[serde(rename_all = "camelCase")]
8190
pub struct PriceAttestation {
8291
#[serde(serialize_with = "pubkey_to_hex")]
83-
pub product_id: Identifier,
92+
pub product_id: Identifier,
8493
#[serde(serialize_with = "pubkey_to_hex")]
85-
pub price_id: Identifier,
94+
pub price_id: Identifier,
8695
#[serde(serialize_with = "use_to_string")]
87-
pub price: i64,
96+
pub price: i64,
8897
#[serde(serialize_with = "use_to_string")]
89-
pub conf: u64,
90-
pub expo: i32,
98+
pub conf: u64,
99+
pub expo: i32,
91100
#[serde(serialize_with = "use_to_string")]
92-
pub ema_price: i64,
101+
pub ema_price: i64,
93102
#[serde(serialize_with = "use_to_string")]
94-
pub ema_conf: u64,
95-
pub status: PriceStatus,
96-
pub num_publishers: u32,
97-
pub max_num_publishers: u32,
98-
pub attestation_time: UnixTimestamp,
99-
pub publish_time: UnixTimestamp,
100-
pub prev_publish_time: UnixTimestamp,
103+
pub ema_conf: u64,
104+
pub status: PriceStatus,
105+
pub num_publishers: u32,
106+
pub max_num_publishers: u32,
107+
pub attestation_time: UnixTimestamp,
108+
pub publish_time: UnixTimestamp,
109+
pub prev_publish_time: UnixTimestamp,
101110
#[serde(serialize_with = "use_to_string")]
102-
pub prev_price: i64,
111+
pub prev_price: i64,
103112
#[serde(serialize_with = "use_to_string")]
104-
pub prev_conf: u64,
113+
pub prev_conf: u64,
114+
pub last_attested_publish_time: UnixTimestamp,
105115
}
106116

107117
/// Helper allowing ToString implementers to be serialized as strings accordingly
@@ -146,6 +156,10 @@ impl BatchPriceAttestation {
146156
// payload_id
147157
buf.push(PayloadId::PriceBatchAttestation as u8);
148158

159+
// Header is over. NOTE: If you need to append to the header,
160+
// make sure that the number of bytes after hdr_size is
161+
// reflected in the P2W_FORMAT_HDR_SIZE constant.
162+
149163
// n_attestations
150164
buf.extend_from_slice(&(self.price_attestations.len() as u16).to_be_bytes()[..]);
151165

@@ -279,11 +293,25 @@ impl PriceAttestation {
279293
pub fn from_pyth_price_bytes(
280294
price_id: Identifier,
281295
attestation_time: UnixTimestamp,
296+
last_attested_publish_time: UnixTimestamp,
282297
value: &[u8],
283298
) -> Result<Self, ErrBox> {
284-
let price = pyth_sdk_solana::state::load_price_account(value)?;
285-
286-
Ok(PriceAttestation {
299+
let price_struct = pyth_sdk_solana::state::load_price_account(value)?;
300+
Ok(Self::from_pyth_price_struct(
301+
price_id,
302+
attestation_time,
303+
last_attested_publish_time,
304+
price_struct,
305+
))
306+
}
307+
#[cfg(feature = "solana")]
308+
pub fn from_pyth_price_struct(
309+
price_id: Identifier,
310+
attestation_time: UnixTimestamp,
311+
last_attested_publish_time: UnixTimestamp,
312+
price: &PriceAccount,
313+
) -> Self {
314+
PriceAttestation {
287315
product_id: Identifier::new(price.prod.val),
288316
price_id,
289317
price: price.agg.price,
@@ -299,7 +327,8 @@ impl PriceAttestation {
299327
prev_publish_time: price.prev_timestamp,
300328
prev_price: price.prev_price,
301329
prev_conf: price.prev_conf,
302-
})
330+
last_attested_publish_time,
331+
}
303332
}
304333

305334
/// Serialize this attestation according to the Pyth-over-wormhole serialization format
@@ -322,6 +351,7 @@ impl PriceAttestation {
322351
prev_publish_time,
323352
prev_price,
324353
prev_conf,
354+
last_attested_publish_time,
325355
} = self;
326356

327357
let mut buf = Vec::new();
@@ -371,6 +401,9 @@ impl PriceAttestation {
371401
// prev_conf
372402
buf.extend_from_slice(&prev_conf.to_be_bytes()[..]);
373403

404+
// last_attested_publish_time
405+
buf.extend_from_slice(&last_attested_publish_time.to_be_bytes()[..]);
406+
374407
buf
375408
}
376409
pub fn deserialize(mut bytes: impl Read) -> Result<Self, ErrBox> {
@@ -444,6 +477,11 @@ impl PriceAttestation {
444477
bytes.read_exact(prev_conf_vec.as_mut_slice())?;
445478
let prev_conf = u64::from_be_bytes(prev_conf_vec.as_slice().try_into()?);
446479

480+
let mut last_attested_publish_time_vec = vec![0u8; mem::size_of::<UnixTimestamp>()];
481+
bytes.read_exact(last_attested_publish_time_vec.as_mut_slice())?;
482+
let last_attested_publish_time =
483+
UnixTimestamp::from_be_bytes(last_attested_publish_time_vec.as_slice().try_into()?);
484+
447485
Ok(Self {
448486
product_id,
449487
price_id,
@@ -460,6 +498,7 @@ impl PriceAttestation {
460498
prev_publish_time,
461499
prev_price,
462500
prev_conf,
501+
last_attested_publish_time,
463502
})
464503
}
465504
}
@@ -478,21 +517,22 @@ mod tests {
478517
let product_id_bytes = prod.unwrap_or([21u8; 32]);
479518
let price_id_bytes = price.unwrap_or([222u8; 32]);
480519
PriceAttestation {
481-
product_id: Identifier::new(product_id_bytes),
482-
price_id: Identifier::new(price_id_bytes),
483-
price: 0x2bad2feed7,
484-
conf: 101,
485-
ema_price: -42,
486-
ema_conf: 42,
487-
expo: -3,
488-
status: PriceStatus::Trading,
489-
num_publishers: 123212u32,
490-
max_num_publishers: 321232u32,
491-
attestation_time: (0xdeadbeeffadedeedu64) as i64,
492-
publish_time: 0xdadebeefi64,
493-
prev_publish_time: 0xdeadbabei64,
494-
prev_price: 0xdeadfacebeefi64,
495-
prev_conf: 0xbadbadbeefu64, // I could do this all day -SD
520+
product_id: Identifier::new(product_id_bytes),
521+
price_id: Identifier::new(price_id_bytes),
522+
price: 0x2bad2feed7,
523+
conf: 101,
524+
ema_price: -42,
525+
ema_conf: 42,
526+
expo: -3,
527+
status: PriceStatus::Trading,
528+
num_publishers: 123212u32,
529+
max_num_publishers: 321232u32,
530+
attestation_time: (0xdeadbeeffadedeedu64) as i64,
531+
publish_time: 0xdadebeefi64,
532+
prev_publish_time: 0xdeadbabei64,
533+
prev_price: 0xdeadfacebeefi64,
534+
prev_conf: 0xbadbadbeefu64, // I could do this all day -SD
535+
last_attested_publish_time: (0xdeadbeeffadedeafu64) as i64,
496536
}
497537
}
498538

wormhole-attester/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

wormhole-attester/client/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ use {
4949
load_product_account,
5050
},
5151
pyth_wormhole_attester::{
52+
attestation_state::AttestationStateMapPDA,
5253
config::{
5354
OldP2WConfigAccount,
5455
P2WConfigAccount,
@@ -324,6 +325,8 @@ pub fn gen_attest_tx(
324325
AccountMeta::new_readonly(system_program::id(), false),
325326
// config
326327
AccountMeta::new_readonly(p2w_config_addr, false),
328+
// attestation_state
329+
AccountMeta::new(AttestationStateMapPDA::key(None, &p2w_addr), false),
327330
];
328331

329332
// Batch contents and padding if applicable

wormhole-attester/program/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,4 @@ p2w-sdk = { path = "../../third_party/pyth/p2w-sdk/rust", features = ["solana"]
2525
serde = { version = "1", optional = true}
2626
serde_derive = { version = "1", optional = true}
2727
serde_json = { version = "1", optional = true}
28+
pyth-sdk-solana = { version = "0.5.0" }

wormhole-attester/program/src/attest.rs

Lines changed: 97 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
use {
22
crate::{
3+
attestation_state::{
4+
AttestationState,
5+
AttestationStateMapPDA,
6+
},
37
config::P2WConfigAccount,
48
message::{
59
P2WMessage,
@@ -20,6 +24,7 @@ use {
2024
P2WEmitter,
2125
PriceAttestation,
2226
},
27+
pyth_sdk_solana::state::PriceStatus,
2328
solana_program::{
2429
clock::Clock,
2530
program::{
@@ -34,6 +39,7 @@ use {
3439
solitaire::{
3540
trace,
3641
AccountState,
42+
CreationLamports,
3743
ExecutionContext,
3844
FromAccounts,
3945
Info,
@@ -60,9 +66,10 @@ pub const P2W_MAX_BATCH_SIZE: u16 = 5;
6066
#[derive(FromAccounts)]
6167
pub struct Attest<'b> {
6268
// Payer also used for wormhole
63-
pub payer: Mut<Signer<Info<'b>>>,
64-
pub system_program: Info<'b>,
65-
pub config: P2WConfigAccount<'b, { AccountState::Initialized }>,
69+
pub payer: Mut<Signer<Info<'b>>>,
70+
pub system_program: Info<'b>,
71+
pub config: P2WConfigAccount<'b, { AccountState::Initialized }>,
72+
pub attestation_state: Mut<AttestationStateMapPDA<'b>>,
6673

6774
// Hardcoded product/price pairs, bypassing Solitaire's variable-length limitations
6875
// 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
152159
return Err(ProgramError::InvalidAccountData.into());
153160
}
154161

162+
155163
// Make the specified prices iterable
156164
let price_pair_opts = [
157165
Some(&accs.pyth_product),
@@ -204,16 +212,48 @@ pub fn attest(ctx: &ExecutionContext, accs: &mut Attest, data: AttestData) -> So
204212
));
205213
return Err(SolitaireError::InvalidOwner(*accs.pyth_price.owner));
206214
}
207-
208-
let attestation = PriceAttestation::from_pyth_price_bytes(
215+
let attestation_time = accs.clock.unix_timestamp;
216+
217+
let price_data_ref = price.try_borrow_data()?;
218+
219+
// Parse the upstream Pyth struct to extract current publish
220+
// time for payload construction
221+
let price_struct =
222+
pyth_sdk_solana::state::load_price_account(&price_data_ref).map_err(|e| {
223+
trace!(&e.to_string());
224+
ProgramError::InvalidAccountData
225+
})?;
226+
227+
// prev_publish_time is picked if the price is not trading
228+
let last_trading_publish_time = match price_struct.agg.status {
229+
PriceStatus::Trading => price_struct.timestamp,
230+
_ => price_struct.prev_timestamp,
231+
};
232+
233+
// Take a mut reference to this price's metadata
234+
let state_entry: &mut AttestationState = accs
235+
.attestation_state
236+
.entries
237+
.entry(*price.key)
238+
.or_insert(AttestationState {
239+
// Use the same value if no state
240+
// exists for the symbol, the new value _becomes_ the
241+
// last attested trading publish time
242+
last_attested_trading_publish_time: last_trading_publish_time,
243+
});
244+
245+
let attestation = PriceAttestation::from_pyth_price_struct(
209246
Identifier::new(price.key.to_bytes()),
210-
accs.clock.unix_timestamp,
211-
&price.try_borrow_data()?,
212-
)
213-
.map_err(|e| {
214-
trace!(&e.to_string());
215-
ProgramError::InvalidAccountData
216-
})?;
247+
attestation_time,
248+
state_entry.last_attested_trading_publish_time, // Used as last_attested_publish_time
249+
price_struct,
250+
);
251+
252+
253+
// update last_attested_publish_time with this price's
254+
// publish_time. Yes, it may be redundant for the entry() used
255+
// above in the rare first attestation edge case.
256+
state_entry.last_attested_trading_publish_time = last_trading_publish_time;
217257

218258
// The following check is crucial against poorly ordered
219259
// account inputs, e.g. [Some(prod1), Some(price1),
@@ -240,6 +280,51 @@ pub fn attest(ctx: &ExecutionContext, accs: &mut Attest, data: AttestData) -> So
240280

241281
trace!("Attestations successfully created");
242282

283+
// Serialize the state to calculate rent/account size adjustments
284+
let serialized = accs.attestation_state.1.try_to_vec()?;
285+
286+
if accs.attestation_state.is_initialized() {
287+
accs.attestation_state
288+
.info()
289+
.realloc(serialized.len(), false)?;
290+
trace!("Attestation state resize OK");
291+
292+
let target_rent = CreationLamports::Exempt.amount(serialized.len());
293+
let current_rent = accs.attestation_state.info().lamports();
294+
295+
// Adjust rent, but only if there isn't enough
296+
if target_rent > current_rent {
297+
let transfer_amount = target_rent - current_rent;
298+
299+
let transfer_ix = system_instruction::transfer(
300+
accs.payer.info().key,
301+
accs.attestation_state.info().key,
302+
transfer_amount,
303+
);
304+
305+
invoke(&transfer_ix, ctx.accounts)?;
306+
}
307+
308+
trace!("Attestation state rent transfer OK");
309+
} else {
310+
let seeds = accs
311+
.attestation_state
312+
.self_bumped_seeds(None, ctx.program_id);
313+
solitaire::create_account(
314+
ctx,
315+
accs.attestation_state.info(),
316+
accs.payer.key,
317+
solitaire::CreationLamports::Exempt,
318+
serialized.len(),
319+
ctx.program_id,
320+
solitaire::IsSigned::SignedWithSeeds(&[seeds
321+
.iter()
322+
.map(|s| s.as_slice())
323+
.collect::<Vec<_>>()
324+
.as_slice()]),
325+
)?;
326+
trace!("Attestation state init OK");
327+
}
243328
let bridge_config = BridgeData::try_from_slice(&accs.wh_bridge.try_borrow_mut_data()?)?.config;
244329

245330
// Pay wormhole fee

0 commit comments

Comments
 (0)