Skip to content

Commit d52f4d4

Browse files
authored
wormhole_attester: per-symbol PDAs for attestation state (#567)
* wormhole-attester: Add a last trading publish timestamp field This change bumps price batch format to v3.1 with a new backwards compatible field - last attested trading publish time. This is the last time we've successfully attested a trading price. If no prior record exists, the current publish time is used. The new field is backed by a new PDA kind for the attester contract, called 'attestation state'. In these PDAs, we store metadata for every price, seeded by its pubkey. Currently, the metadata stores just the last tradind 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 * wormhole_attester: per-symbol state PDAs, stop using prod accounts * attester: Use Option to detect if previous state exists Using Option<> for this makes fallback to latest value more convenient * wormhole_attester: client AccountMeta typo * wormhole_attester: fix mutability error * wormhole_attester: stop using Option<> for on-chain state * wormhole_attester: remove unused realloc logic for attestation state
1 parent 611b240 commit d52f4d4

File tree

4 files changed

+138
-167
lines changed

4 files changed

+138
-167
lines changed

wormhole_attester/client/src/lib.rs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ use {
4848
load_product_account,
4949
},
5050
pyth_wormhole_attester::{
51-
attestation_state::AttestationStateMapPDA,
51+
attestation_state::AttestationStatePDA,
5252
config::{
5353
OldP2WConfigAccount,
5454
P2WConfigAccount,
@@ -325,17 +325,16 @@ pub fn gen_attest_tx(
325325
AccountMeta::new_readonly(system_program::id(), false),
326326
// config
327327
AccountMeta::new_readonly(p2w_config_addr, false),
328-
// attestation_state
329-
AccountMeta::new(AttestationStateMapPDA::key(None, &p2w_addr), false),
330328
];
331329

332330
// Batch contents and padding if applicable
333331
let mut padded_symbols = {
334332
let mut not_padded: Vec<_> = symbols
335333
.iter()
336334
.flat_map(|s| {
335+
let state_address = AttestationStatePDA::key(&s.price_addr, &p2w_addr);
337336
vec![
338-
AccountMeta::new_readonly(s.product_addr, false),
337+
AccountMeta::new(state_address, false),
339338
AccountMeta::new_readonly(s.price_addr, false),
340339
]
341340
})

wormhole_attester/program/src/attest.rs

Lines changed: 106 additions & 148 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
use {
22
crate::{
3-
attestation_state::{
4-
AttestationState,
5-
AttestationStateMapPDA,
6-
},
3+
attestation_state::AttestationStatePDA,
74
config::P2WConfigAccount,
85
message::{
96
P2WMessage,
@@ -39,7 +36,6 @@ use {
3936
solitaire::{
4037
trace,
4138
AccountState,
42-
CreationLamports,
4339
ExecutionContext,
4440
FromAccounts,
4541
Info,
@@ -57,7 +53,7 @@ use {
5753
/// Important: must be manually maintained until native Solitaire
5854
/// variable len vector support.
5955
///
60-
/// The number must reflect how many pyth product/price pairs are
56+
/// The number must reflect how many pyth state/price pairs are
6157
/// expected in the Attest struct below. The constant itself is only
6258
/// used in the on-chain config in order for attesters to learn the
6359
/// correct value dynamically.
@@ -66,42 +62,41 @@ pub const P2W_MAX_BATCH_SIZE: u16 = 5;
6662
#[derive(FromAccounts)]
6763
pub struct Attest<'b> {
6864
// Payer also used for wormhole
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>>,
65+
pub payer: Mut<Signer<Info<'b>>>,
66+
pub system_program: Info<'b>,
67+
pub config: P2WConfigAccount<'b, { AccountState::Initialized }>,
7368

74-
// Hardcoded product/price pairs, bypassing Solitaire's variable-length limitations
69+
// Hardcoded state/price pairs, bypassing Solitaire's variable-length limitations
7570
// Any change to the number of accounts must include an appropriate change to P2W_MAX_BATCH_SIZE
76-
pub pyth_product: Info<'b>,
77-
pub pyth_price: Info<'b>,
71+
pub pyth_state: Mut<AttestationStatePDA<'b>>,
72+
pub pyth_price: Info<'b>,
7873

79-
pub pyth_product2: Option<Info<'b>>,
80-
pub pyth_price2: Option<Info<'b>>,
74+
pub pyth_state2: Option<Mut<AttestationStatePDA<'b>>>,
75+
pub pyth_price2: Option<Info<'b>>,
8176

82-
pub pyth_product3: Option<Info<'b>>,
83-
pub pyth_price3: Option<Info<'b>>,
77+
pub pyth_state3: Option<Mut<AttestationStatePDA<'b>>>,
78+
pub pyth_price3: Option<Info<'b>>,
8479

85-
pub pyth_product4: Option<Info<'b>>,
86-
pub pyth_price4: Option<Info<'b>>,
80+
pub pyth_state4: Option<Mut<AttestationStatePDA<'b>>>,
81+
pub pyth_price4: Option<Info<'b>>,
8782

88-
pub pyth_product5: Option<Info<'b>>,
89-
pub pyth_price5: Option<Info<'b>>,
83+
pub pyth_state5: Option<Mut<AttestationStatePDA<'b>>>,
84+
pub pyth_price5: Option<Info<'b>>,
9085

91-
// Did you read the comment near `pyth_product`?
92-
// pub pyth_product6: Option<Info<'b>>,
86+
// Did you read the comment near `pyth_state`?
87+
// pub pyth_state6: Option<Mut<AttestationStatePDA<'b>>>,
9388
// pub pyth_price6: Option<Info<'b>>,
9489

95-
// pub pyth_product7: Option<Info<'b>>,
90+
// pub pyth_state7: Option<Mut<AttestationStatePDA<'b>>>,
9691
// pub pyth_price7: Option<Info<'b>>,
9792

98-
// pub pyth_product8: Option<Info<'b>>,
93+
// pub pyth_state8: Option<Mut<AttestationStatePDA<'b>>>,
9994
// pub pyth_price8: Option<Info<'b>>,
10095

101-
// pub pyth_product9: Option<Info<'b>>,
96+
// pub pyth_state9: Option<Mut<AttestationStatePDA<'b>>>,
10297
// pub pyth_price9: Option<Info<'b>>,
10398

104-
// pub pyth_product10: Option<Info<'b>>,
99+
// pub pyth_state10: Option<Mut<AttestationStatePDA<'b>>>,
105100
// pub pyth_price10: Option<Info<'b>>,
106101
pub clock: Sysvar<'b, Clock>,
107102

@@ -161,57 +156,55 @@ pub fn attest(ctx: &ExecutionContext, accs: &mut Attest, data: AttestData) -> So
161156

162157

163158
// Make the specified prices iterable
164-
let price_pair_opts = [
165-
Some(&accs.pyth_product),
166-
Some(&accs.pyth_price),
167-
accs.pyth_product2.as_ref(),
168-
accs.pyth_price2.as_ref(),
169-
accs.pyth_product3.as_ref(),
170-
accs.pyth_price3.as_ref(),
171-
accs.pyth_product4.as_ref(),
172-
accs.pyth_price4.as_ref(),
173-
accs.pyth_product5.as_ref(),
174-
accs.pyth_price5.as_ref(),
175-
// Did you read the comment near `pyth_product`?
176-
// accs.pyth_product6.as_ref(),
177-
// accs.pyth_price6.as_ref(),
178-
// accs.pyth_product7.as_ref(),
179-
// accs.pyth_price7.as_ref(),
180-
// accs.pyth_product8.as_ref(),
181-
// accs.pyth_price8.as_ref(),
182-
// accs.pyth_product9.as_ref(),
183-
// accs.pyth_price9.as_ref(),
184-
// accs.pyth_product10.as_ref(),
185-
// accs.pyth_price10.as_ref(),
159+
let mut price_pair_opts = [
160+
(Some(&mut accs.pyth_state), Some(&accs.pyth_price)),
161+
(accs.pyth_state2.as_mut(), accs.pyth_price2.as_ref()),
162+
(accs.pyth_state3.as_mut(), accs.pyth_price3.as_ref()),
163+
(accs.pyth_state4.as_mut(), accs.pyth_price4.as_ref()),
164+
(accs.pyth_state5.as_mut(), accs.pyth_price5.as_ref()),
165+
// Did you read the comment near `pyth_state`?
166+
// (accs.pyth_state6.as_mut(), accs.pyth_price6.as_ref()),
167+
// (accs.pyth_state7.as_mut(), accs.pyth_price7.as_ref()),
168+
// (accs.pyth_state8.as_mut(), accs.pyth_price8.as_ref()),
169+
// (accs.pyth_state9.as_mut(), accs.pyth_price9.as_ref()),
170+
// (accs.pyth_state10.as_mut(), accs.pyth_price10.as_ref()),
186171
];
187172

188-
let price_pairs: Vec<_> = price_pair_opts.iter().filter_map(|acc| *acc).collect();
173+
let price_pairs: Vec<(_, _)> = price_pair_opts
174+
.iter_mut()
175+
.filter_map(|pair| match pair {
176+
// Only use this pair if both accounts are Some
177+
(Some(state), Some(price)) => Some((state, price)),
178+
_other => None,
179+
})
180+
.collect();
189181

190-
if price_pairs.len() % 2 != 0 {
191-
trace!(&format!(
192-
"Uneven product/price count detected: {}",
193-
price_pairs.len()
194-
));
195-
return Err(ProgramError::InvalidAccountData.into());
196-
}
197182

198-
trace!("{} Pyth symbols received", price_pairs.len() / 2);
183+
trace!("{} Pyth symbols received", price_pairs.len());
199184

200-
// Collect the validated symbols for batch serialization
201-
let mut attestations = Vec::with_capacity(price_pairs.len() / 2);
185+
// Collect the validated symbols here for batch serialization
186+
let mut attestations = Vec::with_capacity(price_pairs.len());
202187

203-
for pair in price_pairs.as_slice().chunks_exact(2) {
204-
let product = pair[0];
205-
let price = pair[1];
188+
for (state, price) in price_pairs.into_iter() {
189+
// Pyth must own the price
190+
if accs.config.pyth_owner != *price.owner {
191+
trace!(&format!(
192+
"Price {:?}: owner pubkey mismatch (expected pyth_owner {:?}, got unknown price owner {:?})",
193+
price, accs.config.pyth_owner, price.owner
194+
));
195+
return Err(SolitaireError::InvalidOwner(*price.owner));
196+
}
206197

207-
if accs.config.pyth_owner != *price.owner || accs.config.pyth_owner != *product.owner {
198+
// State pubkey must reproduce from the price id
199+
let state_addr_from_price = AttestationStatePDA::key(price.key, ctx.program_id);
200+
if state_addr_from_price != *state.0 .0.info().key {
208201
trace!(&format!(
209-
"Pair {:?} - {:?}: pyth_owner pubkey mismatch (expected {:?}, got product owner {:?} and price owner {:?}",
210-
product, price,
211-
accs.config.pyth_owner, product.owner, price.owner
212-
));
213-
return Err(SolitaireError::InvalidOwner(*accs.pyth_price.owner));
202+
"Price {:?}: pubkey does not produce the passed state account (expected {:?} from seeds, {:?} was passed)",
203+
price.key, state_addr_from_price, state.0.0.info().key
204+
));
205+
return Err(ProgramError::InvalidAccountData.into());
214206
}
207+
215208
let attestation_time = accs.clock.unix_timestamp;
216209

217210
let price_data_ref = price.try_borrow_data()?;
@@ -224,53 +217,63 @@ pub fn attest(ctx: &ExecutionContext, accs: &mut Attest, data: AttestData) -> So
224217
ProgramError::InvalidAccountData
225218
})?;
226219

227-
// prev_publish_time is picked if the price is not trading
228-
let last_trading_publish_time = match price_struct.agg.status {
220+
// Retrieve and rotate last_attested_tradind_publish_time
221+
222+
// Pick the value to store for the next attestation of this
223+
// symbol. We use the prev_ value if the symbol is not
224+
// currently being traded. The oracle marks the last known
225+
// trading timestamp with it.
226+
let new_last_attested_trading_publish_time = match price_struct.agg.status {
229227
PriceStatus::Trading => price_struct.timestamp,
230228
_ => price_struct.prev_timestamp,
231229
};
232230

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-
});
231+
// Retrieve the timestamp saved during the previous
232+
// attestation. Use the new_* value if no existind state is
233+
// present on-chain
234+
let current_last_attested_trading_publish_time = if state.0 .0.is_initialized() {
235+
// Use the existing on-chain value
236+
state.0 .0 .1.last_attested_trading_publish_time
237+
} else {
238+
// Fall back to the new value if the state is not initialized
239+
new_last_attested_trading_publish_time
240+
};
244241

242+
// Build an attestatioin struct for this symbol using the just decided current value
245243
let attestation = PriceAttestation::from_pyth_price_struct(
246244
Identifier::new(price.key.to_bytes()),
247245
attestation_time,
248-
state_entry.last_attested_trading_publish_time, // Used as last_attested_publish_time
246+
current_last_attested_trading_publish_time,
249247
price_struct,
250248
);
251249

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;
257-
258-
// The following check is crucial against poorly ordered
259-
// account inputs, e.g. [Some(prod1), Some(price1),
260-
// Some(prod2), None, None, Some(price)], interpreted by
261-
// earlier logic as [(prod1, price1), (prod2, price3)].
262-
//
263-
// Failing to verify the product/price relationship could lead
264-
// to mismatched product/price metadata, which would result in
265-
// a false attestation.
266-
if attestation.product_id.to_bytes() != product.key.to_bytes() {
267-
trace!(&format!(
268-
"Price's product_id does not match the pased account (points at {:?} instead)",
269-
attestation.product_id
270-
));
271-
return Err(ProgramError::InvalidAccountData.into());
250+
// Save the new value for the next attestation of this symbol
251+
state.0 .0.last_attested_trading_publish_time = new_last_attested_trading_publish_time;
252+
253+
// handling of last_attested_trading_publish_time ends here
254+
255+
if !state.0 .0.is_initialized() {
256+
// Serialize the state to learn account size for creation
257+
let state_serialized = state.0 .0 .1.try_to_vec()?;
258+
259+
let seeds = state.self_bumped_seeds(price.key, ctx.program_id);
260+
solitaire::create_account(
261+
ctx,
262+
state.0 .0.info(),
263+
accs.payer.key,
264+
solitaire::CreationLamports::Exempt,
265+
state_serialized.len(),
266+
ctx.program_id,
267+
solitaire::IsSigned::SignedWithSeeds(&[seeds
268+
.iter()
269+
.map(|s| s.as_slice())
270+
.collect::<Vec<_>>()
271+
.as_slice()]),
272+
)?;
273+
trace!("Attestation state init OK");
272274
}
273275

276+
274277
attestations.push(attestation);
275278
}
276279

@@ -280,51 +283,6 @@ pub fn attest(ctx: &ExecutionContext, accs: &mut Attest, data: AttestData) -> So
280283

281284
trace!("Attestations successfully created");
282285

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-
}
328286
let bridge_config = BridgeData::try_from_slice(&accs.wh_bridge.try_borrow_mut_data()?)?.config;
329287

330288
// Pay wormhole fee

0 commit comments

Comments
 (0)