Skip to content

Commit 1978d73

Browse files
authored
attester: Add an on-chain last attestation timestamp and rate limit arg (#622)
* attester: Add an on-chain last attestation timestamp and rate limit In consequence, attester clients are able to rate-limit attestations among _all_ active attesters - because the new last attestation timestamp is kept up to date on chain. Ultimately, this value being shared by concurrent clients, this feature limits our tx expenses while fulfilling our preferred attestation rates. * attester: Use custom error code instead of log for rate limit * attester: Add defaults for default attestation conditions * attester: Use a dedicated function for rate limit default * attester: Option<u32> -> u32 rate limit interval This lets users pass 0 to disable the feature (0-rate limiting means no rate limiting at all), which was not possible with the Option type.
1 parent ae88640 commit 1978d73

File tree

9 files changed

+120
-26
lines changed

9 files changed

+120
-26
lines changed

third_party/pyth/p2w_autoattest.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,9 +116,10 @@
116116
default_attestation_conditions:
117117
min_interval_secs: 10
118118
symbol_groups:
119-
- group_name: fast_interval_only
119+
- group_name: fast_interval_rate_limited
120120
conditions:
121121
min_interval_secs: 1
122+
rate_limit_interval_secs: 2
122123
symbols:
123124
"""
124125

wormhole_attester/client/src/attestation_cfg.rs

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ pub struct AttestationConfig {
6666
/// Attestation conditions that will be used for any symbols included in the mapping
6767
/// that aren't explicitly in one of the groups below, and any groups without explicitly
6868
/// configured attestation conditions.
69+
#[serde(default)]
6970
pub default_attestation_conditions: AttestationConditions,
7071

7172
/// Groups of symbols to publish.
@@ -317,20 +318,35 @@ pub const fn default_min_interval_secs() -> u64 {
317318
60
318319
}
319320

321+
pub const fn default_rate_limit_interval_secs() -> u32 {
322+
1
323+
}
324+
320325
pub const fn default_max_batch_jobs() -> usize {
321326
20
322327
}
323328

324-
/// Spontaneous attestation triggers. Attestation is triggered if any
325-
/// of the active conditions is met. Option<> fields can be
329+
/// Per-group attestation resend rules. Attestation is triggered if
330+
/// any of the active conditions is met. Option<> fields can be
326331
/// de-activated with None. All conditions are inactive by default,
327332
/// except for the non-Option ones.
328333
#[derive(Clone, Debug, Hash, Deserialize, Serialize, PartialEq, Eq)]
329334
pub struct AttestationConditions {
330-
/// Baseline, unconditional attestation interval. Attestation is triggered if the specified interval elapsed since last attestation.
335+
/// Lower bound on attestation rate. Attestation is triggered
336+
/// unconditionally whenever the specified interval elapses since
337+
/// last attestation.
331338
#[serde(default = "default_min_interval_secs")]
332339
pub min_interval_secs: u64,
333340

341+
/// Upper bound on attestation rate. Attesting the same batch
342+
/// before this many seconds pass fails the tx. This limit is
343+
/// enforced on-chain, letting concurret attesters prevent
344+
/// redundant batch resends and tx expenses. NOTE: The client
345+
/// logic does not include rate limit failures in monitoring error
346+
/// counts. 0 effectively disables this feature.
347+
#[serde(default = "default_rate_limit_interval_secs")]
348+
pub rate_limit_interval_secs: u32,
349+
334350
/// Limit concurrent attestation attempts per batch. This setting
335351
/// should act only as a failsafe cap on resource consumption and is
336352
/// best set well above the expected average number of jobs.
@@ -358,6 +374,7 @@ impl AttestationConditions {
358374
max_batch_jobs: _max_batch_jobs,
359375
price_changed_bps,
360376
publish_time_min_delta_secs,
377+
rate_limit_interval_secs: _,
361378
} = self;
362379

363380
price_changed_bps.is_some() || publish_time_min_delta_secs.is_some()
@@ -371,6 +388,7 @@ impl Default for AttestationConditions {
371388
max_batch_jobs: default_max_batch_jobs(),
372389
price_changed_bps: None,
373390
publish_time_min_delta_secs: None,
391+
rate_limit_interval_secs: default_rate_limit_interval_secs(),
374392
}
375393
}
376394
}

wormhole_attester/client/src/lib.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,9 @@ pub fn gen_attest_tx(
298298
wh_msg_id: u64,
299299
symbols: &[P2WSymbol],
300300
latest_blockhash: Hash,
301+
// Desired rate limit interval. If all of the symbols are over
302+
// the limit, the tx will fail. 0 means off.
303+
rate_limit_interval_secs: u32,
301304
) -> Result<Transaction, ErrBoxSend> {
302305
let emitter_addr = P2WEmitter::key(None, &p2w_addr);
303306

@@ -390,8 +393,9 @@ pub fn gen_attest_tx(
390393
let ix_data = (
391394
pyth_wormhole_attester::instruction::Instruction::Attest,
392395
AttestData {
393-
consistency_level: ConsistencyLevel::Confirmed,
396+
consistency_level: ConsistencyLevel::Confirmed,
394397
message_account_id: wh_msg_id,
398+
rate_limit_interval_secs,
395399
},
396400
);
397401

wormhole_attester/client/src/main.rs

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
use {
2+
pyth_wormhole_attester::error::AttesterCustomError,
3+
solana_program::instruction::InstructionError,
4+
solana_sdk::transaction::TransactionError,
5+
};
6+
17
pub mod cli;
28

39
use {
@@ -585,6 +591,7 @@ async fn attestation_sched_job(args: AttestationSchedJobArgs) -> Result<(), ErrB
585591
symbols: batch.symbols.to_vec(),
586592
max_jobs_sema: sema.clone(),
587593
message_q_mtx: message_q_mtx.clone(),
594+
rate_limit_interval_secs: batch.conditions.rate_limit_interval_secs,
588595
});
589596

590597
// This short-lived permit prevents scheduling excess
@@ -603,16 +610,17 @@ async fn attestation_sched_job(args: AttestationSchedJobArgs) -> Result<(), ErrB
603610
/// Arguments for attestation_job(). This struct rules out same-type
604611
/// ordering errors due to the large argument count
605612
pub struct AttestationJobArgs {
606-
pub rlmtx: Arc<RLMutex<RpcCfg>>,
607-
pub batch_no: usize,
608-
pub batch_count: usize,
609-
pub group_name: String,
610-
pub p2w_addr: Pubkey,
611-
pub config: Pyth2WormholeConfig,
612-
pub payer: Keypair,
613-
pub symbols: Vec<P2WSymbol>,
614-
pub max_jobs_sema: Arc<Semaphore>,
615-
pub message_q_mtx: Arc<Mutex<P2WMessageQueue>>,
613+
pub rlmtx: Arc<RLMutex<RpcCfg>>,
614+
pub batch_no: usize,
615+
pub batch_count: usize,
616+
pub group_name: String,
617+
pub p2w_addr: Pubkey,
618+
pub config: Pyth2WormholeConfig,
619+
pub payer: Keypair,
620+
pub symbols: Vec<P2WSymbol>,
621+
pub max_jobs_sema: Arc<Semaphore>,
622+
pub rate_limit_interval_secs: u32,
623+
pub message_q_mtx: Arc<Mutex<P2WMessageQueue>>,
616624
}
617625

618626
/// A future for a single attempt to attest a batch on Solana.
@@ -627,6 +635,7 @@ async fn attestation_job(args: AttestationJobArgs) -> Result<(), ErrBoxSend> {
627635
payer,
628636
symbols,
629637
max_jobs_sema,
638+
rate_limit_interval_secs,
630639
message_q_mtx,
631640
} = args;
632641
let batch_no4err_msg = batch_no;
@@ -662,21 +671,37 @@ async fn attestation_job(args: AttestationJobArgs) -> Result<(), ErrBoxSend> {
662671

663672
let wh_msg_id = message_q_mtx.lock().await.get_account()?.id;
664673

665-
let tx_res: Result<_, ErrBoxSend> = gen_attest_tx(
674+
let tx = gen_attest_tx(
666675
p2w_addr,
667676
&config,
668677
&payer,
669678
wh_msg_id,
670679
symbols.as_slice(),
671680
latest_blockhash,
672-
);
681+
rate_limit_interval_secs,
682+
)?;
673683

674684
let tx_processing_start_time = Instant::now();
675685

676-
let sig = rpc
677-
.send_and_confirm_transaction(&tx_res?)
678-
.map_err(|e| -> ErrBoxSend { e.into() })
679-
.await?;
686+
let sig = match rpc.send_and_confirm_transaction(&tx).await {
687+
Ok(s) => Ok(s),
688+
Err(e) => match e.get_transaction_error() {
689+
Some(TransactionError::InstructionError(_idx, InstructionError::Custom(code)))
690+
if code == AttesterCustomError::AttestRateLimitReached as u32 =>
691+
{
692+
info!(
693+
"Batch {}/{}, group {:?} OK: configured {} second rate limit interval reached, backing off",
694+
batch_no, batch_count, group_name, rate_limit_interval_secs,
695+
);
696+
// Note: We return early if rate limit tx
697+
// error is detected. This ensures that we
698+
// don't count this attempt in ok/err
699+
// monitoring and healthcheck counters.
700+
return Ok(());
701+
}
702+
_other => Err(e),
703+
},
704+
}?;
680705
let tx_data = rpc
681706
.get_transaction_with_config(
682707
&sig,

wormhole_attester/client/tests/test_attest.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ async fn test_happy_path() -> Result<(), p2wc::ErrBoxSend> {
111111
0,
112112
symbols.as_slice(),
113113
ctx.last_blockhash,
114+
0,
114115
)?;
115116

116117
// NOTE: 2022-09-05

wormhole_attester/program/src/attest.rs

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use {
22
crate::{
33
attestation_state::AttestationStatePDA,
44
config::P2WConfigAccount,
5+
error::AttesterCustomError,
56
message::{
67
P2WMessage,
78
P2WMessageDrvData,
@@ -127,8 +128,17 @@ pub struct Attest<'b> {
127128

128129
#[derive(BorshDeserialize, BorshSerialize)]
129130
pub struct AttestData {
130-
pub consistency_level: ConsistencyLevel,
131-
pub message_account_id: u64,
131+
pub consistency_level: ConsistencyLevel,
132+
pub message_account_id: u64,
133+
/// Fail the transaction if the global attestation rate of all
134+
/// symbols in this batch is more frequent than the passed
135+
/// interval. This is checked using the attestation time stored in
136+
/// attestation state. This enables all of the clients to only
137+
/// contribute attestations if their desired interval is not
138+
/// already reached. If at least one symbol has been waiting
139+
/// longer than this interval, we attest the whole batch. 0
140+
/// effectively disables this feature.
141+
pub rate_limit_interval_secs: u32,
132142
}
133143

134144
pub fn attest(ctx: &ExecutionContext, accs: &mut Attest, data: AttestData) -> SoliResult<()> {
@@ -180,6 +190,10 @@ pub fn attest(ctx: &ExecutionContext, accs: &mut Attest, data: AttestData) -> So
180190
// Collect the validated symbols here for batch serialization
181191
let mut attestations = Vec::with_capacity(price_pairs.len());
182192

193+
let this_attestation_time = accs.clock.unix_timestamp;
194+
195+
196+
let mut over_rate_limit = true;
183197
for (state, price) in price_pairs.into_iter() {
184198
// Pyth must own the price
185199
if accs.config.pyth_owner != *price.owner {
@@ -200,8 +214,6 @@ pub fn attest(ctx: &ExecutionContext, accs: &mut Attest, data: AttestData) -> So
200214
return Err(ProgramError::InvalidAccountData.into());
201215
}
202216

203-
let attestation_time = accs.clock.unix_timestamp;
204-
205217
let price_data_ref = price.try_borrow_data()?;
206218

207219
// Parse the upstream Pyth struct to extract current publish
@@ -214,6 +226,7 @@ pub fn attest(ctx: &ExecutionContext, accs: &mut Attest, data: AttestData) -> So
214226

215227
// Retrieve and rotate last_attested_tradind_publish_time
216228

229+
217230
// Pick the value to store for the next attestation of this
218231
// symbol. We use the prev_ value if the symbol is not
219232
// currently being traded. The oracle marks the last known
@@ -237,14 +250,29 @@ pub fn attest(ctx: &ExecutionContext, accs: &mut Attest, data: AttestData) -> So
237250
// Build an attestatioin struct for this symbol using the just decided current value
238251
let attestation = PriceAttestation::from_pyth_price_struct(
239252
Identifier::new(price.key.to_bytes()),
240-
attestation_time,
253+
this_attestation_time,
241254
current_last_attested_trading_publish_time,
242255
price_struct,
243256
);
244257

245258
// Save the new value for the next attestation of this symbol
246259
state.0 .0.last_attested_trading_publish_time = new_last_attested_trading_publish_time;
247260

261+
// don't re-evaluate if at least one symbol was found to be under limit
262+
if over_rate_limit {
263+
// Evaluate rate limit - should be smaller than duration from last attestation
264+
if this_attestation_time - state.0 .0.last_attestation_time
265+
>= data.rate_limit_interval_secs as i64
266+
{
267+
over_rate_limit = false;
268+
} else {
269+
trace!("Price {:?}: over rate limit", price.key);
270+
}
271+
}
272+
273+
// Update last attestation time
274+
state.0 .0.last_attestation_time = this_attestation_time;
275+
248276
// handling of last_attested_trading_publish_time ends here
249277

250278
if !state.0 .0.is_initialized() {
@@ -272,6 +300,14 @@ pub fn attest(ctx: &ExecutionContext, accs: &mut Attest, data: AttestData) -> So
272300
attestations.push(attestation);
273301
}
274302

303+
// Do not proceed if none of the symbols is under rate limit
304+
if over_rate_limit {
305+
trace!("All symbols over limit, bailing out");
306+
return Err(
307+
ProgramError::Custom(AttesterCustomError::AttestRateLimitReached as u32).into(),
308+
);
309+
}
310+
275311
let batch_attestation = BatchPriceAttestation {
276312
price_attestations: attestations,
277313
};

wormhole_attester/program/src/attestation_state.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ use {
2525
pub struct AttestationState {
2626
/// The last trading publish_time this attester saw
2727
pub last_attested_trading_publish_time: UnixTimestamp,
28+
/// The last time this symbol was attested
29+
pub last_attestation_time: UnixTimestamp,
2830
}
2931

3032
impl Owned for AttestationState {
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/// Append-only custom error list.
2+
#[repr(u32)]
3+
pub enum AttesterCustomError {
4+
/// Explicitly checked for in client code, change carefully
5+
AttestRateLimitReached = 13,
6+
}

wormhole_attester/program/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
pub mod attest;
44
pub mod attestation_state;
55
pub mod config;
6+
pub mod error;
67
pub mod initialize;
78
pub mod message;
89
pub mod migrate;

0 commit comments

Comments
 (0)