Skip to content

Commit b0cb32f

Browse files
authored
feat(target_chains/starknet): update if necessary and get no older than (#1614)
* feat(target_chains/starknet): add update_price_feeds_if_necessary() * feat(target_chains/starknet): add get_price_no_older_than * feat(target_chains/starknet): add get_ema_price_no_older_than() * fix(target_chains/starknet): panic if there is no fresh update
1 parent 443b769 commit b0cb32f

File tree

5 files changed

+275
-6
lines changed

5 files changed

+275
-6
lines changed

target_chains/starknet/contracts/src/pyth.cairo

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,17 @@ pub use pyth::{
99
Event, PriceFeedUpdated, WormholeAddressSet, GovernanceDataSourceSet, ContractUpgraded,
1010
DataSourcesSet, FeeSet,
1111
};
12-
pub use errors::{GetPriceUnsafeError, GovernanceActionError, UpdatePriceFeedsError};
13-
pub use interface::{IPyth, IPythDispatcher, IPythDispatcherTrait, DataSource, Price};
12+
pub use errors::{
13+
GetPriceUnsafeError, GovernanceActionError, UpdatePriceFeedsError, GetPriceNoOlderThanError,
14+
UpdatePriceFeedsIfNecessaryError,
15+
};
16+
pub use interface::{
17+
IPyth, IPythDispatcher, IPythDispatcherTrait, DataSource, Price, PriceFeedPublishTime
18+
};
1419

1520
#[starknet::contract]
1621
mod pyth {
22+
use pyth::pyth::interface::IPyth;
1723
use super::price_update::{
1824
PriceInfo, PriceFeedMessage, read_and_verify_message, read_and_verify_header,
1925
parse_wormhole_proof
@@ -23,17 +29,19 @@ mod pyth {
2329
use core::panic_with_felt252;
2430
use core::starknet::{
2531
ContractAddress, get_caller_address, get_execution_info, ClassHash, SyscallResultTrait,
26-
get_contract_address,
32+
get_contract_address, get_block_timestamp,
2733
};
2834
use core::starknet::syscalls::replace_class_syscall;
2935
use pyth::wormhole::{IWormholeDispatcher, IWormholeDispatcherTrait, VerifiedVM};
3036
use super::{
3137
DataSource, UpdatePriceFeedsError, GovernanceActionError, Price, GetPriceUnsafeError,
32-
IPythDispatcher, IPythDispatcherTrait,
38+
IPythDispatcher, IPythDispatcherTrait, PriceFeedPublishTime, GetPriceNoOlderThanError,
39+
UpdatePriceFeedsIfNecessaryError,
3340
};
3441
use super::governance;
3542
use super::governance::GovernancePayload;
3643
use openzeppelin::token::erc20::interface::{IERC20CamelDispatcherTrait, IERC20CamelDispatcher};
44+
use pyth::util::ResultMapErrInto;
3745

3846
#[event]
3947
#[derive(Drop, PartialEq, starknet::Event)]
@@ -143,6 +151,16 @@ mod pyth {
143151

144152
#[abi(embed_v0)]
145153
impl PythImpl of super::IPyth<ContractState> {
154+
fn get_price_no_older_than(
155+
self: @ContractState, price_id: u256, age: u64
156+
) -> Result<Price, GetPriceNoOlderThanError> {
157+
let info = self.get_price_unsafe(price_id).map_err_into()?;
158+
if !is_no_older_than(info.publish_time, age) {
159+
return Result::Err(GetPriceNoOlderThanError::StalePrice);
160+
}
161+
Result::Ok(info)
162+
}
163+
146164
fn get_price_unsafe(
147165
self: @ContractState, price_id: u256
148166
) -> Result<Price, GetPriceUnsafeError> {
@@ -159,6 +177,16 @@ mod pyth {
159177
Result::Ok(price)
160178
}
161179

180+
fn get_ema_price_no_older_than(
181+
self: @ContractState, price_id: u256, age: u64
182+
) -> Result<Price, GetPriceNoOlderThanError> {
183+
let info = self.get_ema_price_unsafe(price_id).map_err_into()?;
184+
if !is_no_older_than(info.publish_time, age) {
185+
return Result::Err(GetPriceNoOlderThanError::StalePrice);
186+
}
187+
Result::Ok(info)
188+
}
189+
162190
fn get_ema_price_unsafe(
163191
self: @ContractState, price_id: u256
164192
) -> Result<Price, GetPriceUnsafeError> {
@@ -229,6 +257,28 @@ mod pyth {
229257
self.get_total_fee(num_updates)
230258
}
231259

260+
fn update_price_feeds_if_necessary(
261+
ref self: ContractState,
262+
update: ByteArray,
263+
required_publish_times: Array<PriceFeedPublishTime>
264+
) {
265+
let mut i = 0;
266+
let mut found = false;
267+
while i < required_publish_times.len() {
268+
let item = required_publish_times.at(i);
269+
let latest_time = self.latest_price_info.read(*item.price_id).publish_time;
270+
if latest_time < *item.publish_time {
271+
self.update_price_feeds(update);
272+
found = true;
273+
break;
274+
}
275+
i += 1;
276+
};
277+
if !found {
278+
panic_with_felt252(UpdatePriceFeedsIfNecessaryError::NoFreshUpdate.into());
279+
}
280+
}
281+
232282
fn execute_governance_instruction(ref self: ContractState, data: ByteArray) {
233283
let wormhole = IWormholeDispatcher { contract_address: self.wormhole_address.read() };
234284
let vm = wormhole.parse_and_verify_vm(data.clone());
@@ -451,4 +501,14 @@ mod pyth {
451501
};
452502
output
453503
}
504+
505+
fn is_no_older_than(publish_time: u64, age: u64) -> bool {
506+
let current = get_block_timestamp();
507+
let actual_age = if current >= publish_time {
508+
current - publish_time
509+
} else {
510+
0
511+
};
512+
actual_age <= age
513+
}
454514
}

target_chains/starknet/contracts/src/pyth/errors.cairo

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,31 @@ impl GetPriceUnsafeErrorIntoFelt252 of Into<GetPriceUnsafeError, felt252> {
1111
}
1212
}
1313

14+
#[derive(Copy, Drop, Debug, Serde, PartialEq)]
15+
pub enum GetPriceNoOlderThanError {
16+
PriceFeedNotFound,
17+
StalePrice,
18+
}
19+
20+
impl GetPriceNoOlderThanErrorIntoFelt252 of Into<GetPriceNoOlderThanError, felt252> {
21+
fn into(self: GetPriceNoOlderThanError) -> felt252 {
22+
match self {
23+
GetPriceNoOlderThanError::PriceFeedNotFound => 'price feed not found',
24+
GetPriceNoOlderThanError::StalePrice => 'stale price',
25+
}
26+
}
27+
}
28+
29+
impl GetPriceUnsafeErrorIntoGetPriceNoOlderThanError of Into<
30+
GetPriceUnsafeError, GetPriceNoOlderThanError
31+
> {
32+
fn into(self: GetPriceUnsafeError) -> GetPriceNoOlderThanError {
33+
match self {
34+
GetPriceUnsafeError::PriceFeedNotFound => GetPriceNoOlderThanError::PriceFeedNotFound,
35+
}
36+
}
37+
}
38+
1439
#[derive(Copy, Drop, Debug, Serde, PartialEq)]
1540
pub enum GovernanceActionError {
1641
AccessDenied,
@@ -56,3 +81,20 @@ impl UpdatePriceFeedsErrorIntoFelt252 of Into<UpdatePriceFeedsError, felt252> {
5681
}
5782
}
5883
}
84+
85+
#[derive(Copy, Drop, Debug, Serde, PartialEq)]
86+
pub enum UpdatePriceFeedsIfNecessaryError {
87+
Update: UpdatePriceFeedsError,
88+
NoFreshUpdate,
89+
}
90+
91+
impl UpdatePriceFeedsIfNecessaryErrorIntoFelt252 of Into<
92+
UpdatePriceFeedsIfNecessaryError, felt252
93+
> {
94+
fn into(self: UpdatePriceFeedsIfNecessaryError) -> felt252 {
95+
match self {
96+
UpdatePriceFeedsIfNecessaryError::Update(err) => err.into(),
97+
UpdatePriceFeedsIfNecessaryError::NoFreshUpdate => 'no fresh update',
98+
}
99+
}
100+
}

target_chains/starknet/contracts/src/pyth/interface.cairo

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,20 @@
1-
use super::GetPriceUnsafeError;
1+
use super::{GetPriceUnsafeError, GetPriceNoOlderThanError};
22
use pyth::byte_array::ByteArray;
33

44
#[starknet::interface]
55
pub trait IPyth<T> {
6+
fn get_price_no_older_than(
7+
self: @T, price_id: u256, age: u64
8+
) -> Result<Price, GetPriceNoOlderThanError>;
69
fn get_price_unsafe(self: @T, price_id: u256) -> Result<Price, GetPriceUnsafeError>;
10+
fn get_ema_price_no_older_than(
11+
self: @T, price_id: u256, age: u64
12+
) -> Result<Price, GetPriceNoOlderThanError>;
713
fn get_ema_price_unsafe(self: @T, price_id: u256) -> Result<Price, GetPriceUnsafeError>;
814
fn update_price_feeds(ref self: T, data: ByteArray);
15+
fn update_price_feeds_if_necessary(
16+
ref self: T, update: ByteArray, required_publish_times: Array<PriceFeedPublishTime>
17+
);
918
fn get_update_fee(self: @T, data: ByteArray) -> u256;
1019
fn execute_governance_instruction(ref self: T, data: ByteArray);
1120
fn pyth_upgradable_magic(self: @T) -> u32;
@@ -24,3 +33,9 @@ pub struct Price {
2433
pub expo: i32,
2534
pub publish_time: u64,
2635
}
36+
37+
#[derive(Drop, Clone, Serde)]
38+
pub struct PriceFeedPublishTime {
39+
pub price_id: u256,
40+
pub publish_time: u64,
41+
}

target_chains/starknet/contracts/src/util.cairo

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,19 @@ pub fn array_try_into<T, U, +TryInto<T, U>, +Drop<T>, +Drop<U>>(mut input: Array
145145
output
146146
}
147147

148+
pub trait ResultMapErrInto<T, E1, E2> {
149+
fn map_err_into(self: Result<T, E1>) -> Result<T, E2>;
150+
}
151+
152+
impl ResultMapErrIntoImpl<T, E1, E2, +Into<E1, E2>> of ResultMapErrInto<T, E1, E2> {
153+
fn map_err_into(self: Result<T, E1>) -> Result<T, E2> {
154+
match self {
155+
Result::Ok(v) => Result::Ok(v),
156+
Result::Err(err) => Result::Err(err.into()),
157+
}
158+
}
159+
}
160+
148161
#[cfg(test)]
149162
mod tests {
150163
use super::{u64_as_i64, u32_as_i32};

target_chains/starknet/contracts/tests/pyth.cairo

Lines changed: 140 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
use snforge_std::{
22
declare, ContractClassTrait, start_prank, stop_prank, CheatTarget, spy_events, SpyOn, EventSpy,
3-
EventFetcher, event_name_hash, Event
3+
EventFetcher, event_name_hash, Event, start_warp, stop_warp
44
};
55
use pyth::pyth::{
66
IPythDispatcher, IPythDispatcherTrait, DataSource, Event as PythEvent, PriceFeedUpdated,
77
WormholeAddressSet, GovernanceDataSourceSet, ContractUpgraded, DataSourcesSet, FeeSet,
8+
PriceFeedPublishTime, GetPriceNoOlderThanError,
89
};
910
use pyth::byte_array::{ByteArray, ByteArrayImpl};
1011
use pyth::util::{array_try_into, UnwrapWithFelt252};
@@ -132,6 +133,144 @@ fn update_price_feeds_works() {
132133
assert!(last_ema_price.publish_time == 1712589206);
133134
}
134135

136+
#[test]
137+
fn test_update_if_necessary_works() {
138+
let user = 'user'.try_into().unwrap();
139+
let wormhole = super::wormhole::deploy_with_test_guardian();
140+
let fee_contract = deploy_fee_contract(user);
141+
let pyth = deploy_default(wormhole.contract_address, fee_contract.contract_address);
142+
143+
start_prank(CheatTarget::One(fee_contract.contract_address), user);
144+
fee_contract.approve(pyth.contract_address, 10000);
145+
stop_prank(CheatTarget::One(fee_contract.contract_address));
146+
147+
let mut spy = spy_events(SpyOn::One(pyth.contract_address));
148+
149+
let price_id = 0xe62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43;
150+
assert!(pyth.get_price_unsafe(price_id).is_err());
151+
152+
start_prank(CheatTarget::One(pyth.contract_address), user);
153+
let times = array![PriceFeedPublishTime { price_id, publish_time: 1715769470 }];
154+
pyth.update_price_feeds_if_necessary(data::test_price_update1(), times);
155+
156+
let last_price = pyth.get_price_unsafe(price_id).unwrap_with_felt252();
157+
assert!(last_price.price == 6281060000000);
158+
assert!(last_price.publish_time == 1715769470);
159+
160+
spy.fetch_events();
161+
assert!(spy.events.len() == 1);
162+
163+
let times = array![PriceFeedPublishTime { price_id, publish_time: 1715769475 }];
164+
pyth.update_price_feeds_if_necessary(data::test_price_update2(), times);
165+
166+
let last_price = pyth.get_price_unsafe(price_id).unwrap_with_felt252();
167+
assert!(last_price.price == 6281522520745);
168+
assert!(last_price.publish_time == 1715769475);
169+
170+
spy.fetch_events();
171+
assert!(spy.events.len() == 2);
172+
173+
stop_prank(CheatTarget::One(pyth.contract_address));
174+
}
175+
176+
#[test]
177+
#[should_panic(expected: ('no fresh update',))]
178+
fn test_update_if_necessary_rejects_empty() {
179+
let user = 'user'.try_into().unwrap();
180+
let wormhole = super::wormhole::deploy_with_test_guardian();
181+
let fee_contract = deploy_fee_contract(user);
182+
let pyth = deploy_default(wormhole.contract_address, fee_contract.contract_address);
183+
184+
start_prank(CheatTarget::One(fee_contract.contract_address), user);
185+
fee_contract.approve(pyth.contract_address, 10000);
186+
stop_prank(CheatTarget::One(fee_contract.contract_address));
187+
188+
start_prank(CheatTarget::One(pyth.contract_address), user);
189+
pyth.update_price_feeds_if_necessary(data::test_price_update1(), array![]);
190+
stop_prank(CheatTarget::One(pyth.contract_address));
191+
}
192+
193+
#[test]
194+
#[should_panic(expected: ('no fresh update',))]
195+
fn test_update_if_necessary_rejects_no_fresh() {
196+
let user = 'user'.try_into().unwrap();
197+
let wormhole = super::wormhole::deploy_with_test_guardian();
198+
let fee_contract = deploy_fee_contract(user);
199+
let pyth = deploy_default(wormhole.contract_address, fee_contract.contract_address);
200+
201+
start_prank(CheatTarget::One(fee_contract.contract_address), user);
202+
fee_contract.approve(pyth.contract_address, 10000);
203+
stop_prank(CheatTarget::One(fee_contract.contract_address));
204+
205+
let mut spy = spy_events(SpyOn::One(pyth.contract_address));
206+
207+
start_prank(CheatTarget::One(pyth.contract_address), user);
208+
pyth.update_price_feeds_if_necessary(data::test_price_update1(), array![]);
209+
210+
let price_id = 0xe62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43;
211+
assert!(pyth.get_price_unsafe(price_id).is_err());
212+
spy.fetch_events();
213+
assert!(spy.events.len() == 0);
214+
215+
let times = array![PriceFeedPublishTime { price_id, publish_time: 1715769470 }];
216+
pyth.update_price_feeds_if_necessary(data::test_price_update1(), times);
217+
218+
let last_price = pyth.get_price_unsafe(price_id).unwrap_with_felt252();
219+
assert!(last_price.price == 6281060000000);
220+
assert!(last_price.publish_time == 1715769470);
221+
222+
spy.fetch_events();
223+
assert!(spy.events.len() == 1);
224+
225+
let times = array![PriceFeedPublishTime { price_id, publish_time: 1715769470 }];
226+
pyth.update_price_feeds_if_necessary(data::test_price_update2(), times);
227+
}
228+
229+
#[test]
230+
fn test_get_no_older_works() {
231+
let user = 'user'.try_into().unwrap();
232+
let wormhole = super::wormhole::deploy_with_mainnet_guardians();
233+
let fee_contract = deploy_fee_contract(user);
234+
let pyth = deploy_default(wormhole.contract_address, fee_contract.contract_address);
235+
let price_id = 0xe62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43;
236+
let err = pyth.get_price_no_older_than(price_id, 100).unwrap_err();
237+
assert!(err == GetPriceNoOlderThanError::PriceFeedNotFound);
238+
let err = pyth.get_ema_price_no_older_than(price_id, 100).unwrap_err();
239+
assert!(err == GetPriceNoOlderThanError::PriceFeedNotFound);
240+
241+
start_prank(CheatTarget::One(fee_contract.contract_address), user.try_into().unwrap());
242+
fee_contract.approve(pyth.contract_address, 10000);
243+
stop_prank(CheatTarget::One(fee_contract.contract_address));
244+
245+
start_prank(CheatTarget::One(pyth.contract_address), user.try_into().unwrap());
246+
pyth.update_price_feeds(data::good_update1());
247+
stop_prank(CheatTarget::One(pyth.contract_address));
248+
249+
start_warp(CheatTarget::One(pyth.contract_address), 1712589210);
250+
let err = pyth.get_price_no_older_than(price_id, 3).unwrap_err();
251+
assert!(err == GetPriceNoOlderThanError::StalePrice);
252+
let err = pyth.get_ema_price_no_older_than(price_id, 3).unwrap_err();
253+
assert!(err == GetPriceNoOlderThanError::StalePrice);
254+
255+
start_warp(CheatTarget::One(pyth.contract_address), 1712589208);
256+
let val = pyth.get_price_no_older_than(price_id, 3).unwrap_with_felt252();
257+
assert!(val.publish_time == 1712589206);
258+
assert!(val.price == 7192002930010);
259+
let val = pyth.get_ema_price_no_older_than(price_id, 3).unwrap_with_felt252();
260+
assert!(val.publish_time == 1712589206);
261+
assert!(val.price == 7181868900000);
262+
263+
start_warp(CheatTarget::One(pyth.contract_address), 1712589204);
264+
let val = pyth.get_price_no_older_than(price_id, 3).unwrap_with_felt252();
265+
assert!(val.publish_time == 1712589206);
266+
assert!(val.price == 7192002930010);
267+
let val = pyth.get_ema_price_no_older_than(price_id, 3).unwrap_with_felt252();
268+
assert!(val.publish_time == 1712589206);
269+
assert!(val.price == 7181868900000);
270+
271+
stop_warp(CheatTarget::One(pyth.contract_address));
272+
}
273+
135274
#[test]
136275
fn test_governance_set_fee_works() {
137276
let user = 'user'.try_into().unwrap();

0 commit comments

Comments
 (0)