From 9303a211b7e85b4f7d7bcf653c431b7f119b8b33 Mon Sep 17 00:00:00 2001 From: optke3 Date: Wed, 22 Mar 2023 17:13:49 +0000 Subject: [PATCH 1/9] state getters and setters, change Move.toml dependency to sui/integration_v2 --- target_chains/sui/contracts/Move.toml | 2 +- .../sources/batch_price_attestation.move | 248 ++++++++++++++++++ target_chains/sui/contracts/sources/pyth.move | 1 + .../sui/contracts/sources/state.move | 124 +++++++++ 4 files changed, 374 insertions(+), 1 deletion(-) create mode 100644 target_chains/sui/contracts/sources/batch_price_attestation.move create mode 100644 target_chains/sui/contracts/sources/pyth.move create mode 100644 target_chains/sui/contracts/sources/state.move diff --git a/target_chains/sui/contracts/Move.toml b/target_chains/sui/contracts/Move.toml index 52411cac74..7db7a62dd3 100644 --- a/target_chains/sui/contracts/Move.toml +++ b/target_chains/sui/contracts/Move.toml @@ -10,7 +10,7 @@ rev = "157ac72030d014f17d76cefe81f3915b4afab2c9" [dependencies.Wormhole] git = "https://github.com/wormhole-foundation/wormhole.git" subdir = "sui/wormhole" -rev = "sui/wormhole-cleanup" +rev = "sui/integration_v2" [addresses] pyth = "0x250" diff --git a/target_chains/sui/contracts/sources/batch_price_attestation.move b/target_chains/sui/contracts/sources/batch_price_attestation.move new file mode 100644 index 0000000000..5021535435 --- /dev/null +++ b/target_chains/sui/contracts/sources/batch_price_attestation.move @@ -0,0 +1,248 @@ +module pyth::batch_price_attestation { + + use sui::tx_context::{Self, TxContext}; + + use pyth::price_feed::{Self}; + use pyth::price_info::{Self, PriceInfo}; + use pyth::price_identifier::{Self}; + use pyth::price_status; + use pyth::deserialize::{Self}; + // TODO - Import Sui clock and use it for timekeeping instead of tx_context::epoch. + // Replace epoch in deserialize_price_info with sui clock timestamp, and usage + // of epoch in test_deserialize_batch_price_attestation. + // TODO - Use specific error messages in this module, specifically + // for invalid_attestation_magic_value and invalid_batch_attestation_header_size. + use wormhole::cursor::{Self, Cursor}; + use wormhole::bytes::{Self}; + + use std::vector::{Self}; + + #[test_only] + use pyth::price; + #[test_only] + use pyth::i64; + + const MAGIC: u64 = 0x50325748; // "P2WH" (Pyth2Wormhole) raw ASCII bytes + + struct BatchPriceAttestation { + header: Header, + attestation_size: u64, + attestation_count: u64, + price_infos: vector, + } + + struct Header { + magic: u64, + version_major: u64, + version_minor: u64, + header_size: u64, + payload_id: u8, + } + + fun deserialize_header(cur: &mut Cursor): Header { + let magic = (deserialize::deserialize_u32(cur) as u64); + assert!(magic == MAGIC, 0); // TODO - add specific error value - error::invalid_attestation_magic_value() + let version_major = deserialize::deserialize_u16(cur); + let version_minor = deserialize::deserialize_u16(cur); + let header_size = deserialize::deserialize_u16(cur); + let payload_id = deserialize::deserialize_u8(cur); + + assert!(header_size >= 1, 0); // TODO - add specific error value - error::invalid_batch_attestation_header_size() + let unknown_header_bytes = header_size - 1; + let _unknown = bytes::take_bytes(cur, (unknown_header_bytes as u64)); + + Header { + magic: magic, + header_size: (header_size as u64), + version_minor: (version_minor as u64), + version_major: (version_major as u64), + payload_id: payload_id, + } + } + + public fun destroy(batch: BatchPriceAttestation): vector { + let BatchPriceAttestation { + header: Header { + magic: _, + version_major: _, + version_minor: _, + header_size: _, + payload_id: _, + }, + attestation_size: _, + attestation_count: _, + price_infos, + } = batch; + price_infos + } + + public fun get_attestation_count(batch: &BatchPriceAttestation): u64 { + batch.attestation_count + } + + public fun get_price_info(batch: &BatchPriceAttestation, index: u64): &PriceInfo { + vector::borrow(&batch.price_infos, index) + } + + public fun deserialize(bytes: vector, ctx: &mut TxContext): BatchPriceAttestation { + let cur = cursor::new(bytes); + let header = deserialize_header(&mut cur); + + let attestation_count = deserialize::deserialize_u16(&mut cur); + let attestation_size = deserialize::deserialize_u16(&mut cur); + let price_infos = vector::empty(); + + let i = 0; + while (i < attestation_count) { + let price_info = deserialize_price_info(&mut cur, ctx); + vector::push_back(&mut price_infos, price_info); + + // Consume any excess bytes + let parsed_bytes = 32+32+8+8+4+8+8+1+4+4+8+8+8+8+8; + let _excess = bytes::take_bytes(&mut cur, (attestation_size - parsed_bytes as u64)); + + i = i + 1; + }; + cursor::destroy_empty(cur); + + BatchPriceAttestation { + header, + attestation_count: (attestation_count as u64), + attestation_size: (attestation_size as u64), + price_infos: price_infos, + } + } + + fun deserialize_price_info(cur: &mut Cursor, ctx: &mut TxContext): PriceInfo { + + // Skip obselete field + let _product_identifier = deserialize::deserialize_vector(cur, 32); + let price_identifier = price_identifier::from_byte_vec(deserialize::deserialize_vector(cur, 32)); + let price = deserialize::deserialize_i64(cur); + let conf = deserialize::deserialize_u64(cur); + let expo = deserialize::deserialize_i32(cur); + let ema_price = deserialize::deserialize_i64(cur); + let ema_conf = deserialize::deserialize_u64(cur); + let status = price_status::from_u64((deserialize::deserialize_u8(cur) as u64)); + + // Skip obselete fields + let _num_publishers = deserialize::deserialize_u32(cur); + let _max_num_publishers = deserialize::deserialize_u32(cur); + + let attestation_time = deserialize::deserialize_u64(cur); + let publish_time = deserialize::deserialize_u64(cur); // + let prev_publish_time = deserialize::deserialize_u64(cur); + let prev_price = deserialize::deserialize_i64(cur); + let prev_conf = deserialize::deserialize_u64(cur); + + // Handle the case where the status is not trading. This logic will soon be moved into + // the attester. + + // If status is trading, use the current price. + // If not, use the the last known trading price. + let current_price = pyth::price::new(price, conf, expo, publish_time); + if (status != price_status::new_trading()) { + current_price = pyth::price::new(prev_price, prev_conf, expo, prev_publish_time); + }; + + // If status is trading, use the timestamp of the aggregate as the timestamp for the + // EMA price. If not, the EMA will have last been updated when the aggregate last had + // trading status, so use prev_publish_time (the time when the aggregate last had trading status). + let ema_timestamp = publish_time; + if (status != price_status::new_trading()) { + ema_timestamp = prev_publish_time; + }; + + price_info::new( + attestation_time, + tx_context::epoch(ctx), //TODO - use Sui Clock to get timestamp in seconds + price_feed::new( + price_identifier, + current_price, + pyth::price::new(ema_price, ema_conf, expo, ema_timestamp), + ) + ) + } + + #[test] + #[expected_failure] + fun test_deserialize_batch_price_attestation_invalid_magic() { + use sui::test_scenario::{Self, ctx}; + let test = test_scenario::begin(@0x1234); + + // A batch price attestation with a magic number of 0x50325749 + let bytes = x"5032574900030000000102000400951436e0be37536be96f0896366089506a59763d036728332d3e3038047851aea7c6c75c89f14810ec1c54c03ab8f1864a4c4032791f05747f560faec380a695d1000000000000049a0000000000000008fffffffb00000000000005dc0000000000000003000000000100000001000000006329c0eb000000006329c0e9000000006329c0e400000000000006150000000000000007215258d81468614f6b7e194c5d145609394f67b041e93e6695dcc616faadd0603b9551a68d01d954d6387aff4df1529027ffb2fee413082e509feb29cc4904fe000000000000041a0000000000000003fffffffb00000000000005cb0000000000000003010000000100000001000000006329c0eb000000006329c0e9000000006329c0e4000000000000048600000000000000078ac9cf3ab299af710d735163726fdae0db8465280502eb9f801f74b3c1bd190333832fad6e36eb05a8972fe5f219b27b5b2bb2230a79ce79beb4c5c5e7ecc76d00000000000003f20000000000000002fffffffb00000000000005e70000000000000003010000000100000001000000006329c0eb000000006329c0e9000000006329c0e40000000000000685000000000000000861db714e9ff987b6fedf00d01f9fea6db7c30632d6fc83b7bc9459d7192bc44a21a28b4c6619968bd8c20e95b0aaed7df2187fd310275347e0376a2cd7427db800000000000006cb0000000000000001fffffffb00000000000005e40000000000000003010000000100000001000000006329c0eb000000006329c0e9000000006329c0e400000000000007970000000000000001"; + destroy(deserialize(bytes, ctx(&mut test))); + test_scenario::end(test); + } + + #[test] + fun test_deserialize_batch_price_attestation() { + use sui::test_scenario::{Self, ctx}; + // Set the arrival time + let test = test_scenario::begin(@0x1234); + let arrival_time = tx_context::epoch(ctx(&mut test)); + + // A raw batch price attestation + // The first attestation has a status of UNKNOWN + let bytes = x"5032574800030000000102000400951436e0be37536be96f0896366089506a59763d036728332d3e3038047851aea7c6c75c89f14810ec1c54c03ab8f1864a4c4032791f05747f560faec380a695d1000000000000049a0000000000000008fffffffb00000000000005dc0000000000000003000000000100000001000000006329c0eb000000006329c0e9000000006329c0e400000000000006150000000000000007215258d81468614f6b7e194c5d145609394f67b041e93e6695dcc616faadd0603b9551a68d01d954d6387aff4df1529027ffb2fee413082e509feb29cc4904fe000000000000041a0000000000000003fffffffb00000000000005cb0000000000000003010000000100000001000000006329c0eb000000006329c0e9000000006329c0e4000000000000048600000000000000078ac9cf3ab299af710d735163726fdae0db8465280502eb9f801f74b3c1bd190333832fad6e36eb05a8972fe5f219b27b5b2bb2230a79ce79beb4c5c5e7ecc76d00000000000003f20000000000000002fffffffb00000000000005e70000000000000003010000000100000001000000006329c0eb000000006329c0e9000000006329c0e40000000000000685000000000000000861db714e9ff987b6fedf00d01f9fea6db7c30632d6fc83b7bc9459d7192bc44a21a28b4c6619968bd8c20e95b0aaed7df2187fd310275347e0376a2cd7427db800000000000006cb0000000000000001fffffffb00000000000005e40000000000000003010000000100000001000000006329c0eb000000006329c0e9000000006329c0e400000000000007970000000000000001"; + + let expected = BatchPriceAttestation { + header: Header { + magic: 0x50325748, + version_major: 3, + version_minor: 0, + payload_id: 2, + header_size: 1, + }, + attestation_count: 4, + attestation_size: 149, + price_infos: vector[ + price_info::new( + 1663680747, + arrival_time, + price_feed::new( + price_identifier::from_byte_vec(x"c6c75c89f14810ec1c54c03ab8f1864a4c4032791f05747f560faec380a695d1"), + price::new(i64::new(1557, false), 7, i64::new(5, true), 1663680740), + price::new(i64::new(1500, false), 3, i64::new(5, true), 1663680740), + ), + ), + price_info::new( + 1663680747, + arrival_time, + price_feed::new( + price_identifier::from_byte_vec(x"3b9551a68d01d954d6387aff4df1529027ffb2fee413082e509feb29cc4904fe"), + price::new(i64::new(1050, false), 3, i64::new(5, true), 1663680745), + price::new(i64::new(1483, false), 3, i64::new(5, true), 1663680745), + ), + ), + price_info::new( + 1663680747, + arrival_time, + price_feed::new( + price_identifier::from_byte_vec(x"33832fad6e36eb05a8972fe5f219b27b5b2bb2230a79ce79beb4c5c5e7ecc76d"), + price::new(i64::new(1010, false), 2, i64::new(5, true), 1663680745), + price::new(i64::new(1511, false), 3, i64::new(5, true), 1663680745), + ), + ), + price_info::new( + 1663680747, + arrival_time, + price_feed::new( + price_identifier::from_byte_vec(x"21a28b4c6619968bd8c20e95b0aaed7df2187fd310275347e0376a2cd7427db8"), + price::new(i64::new(1739, false), 1, i64::new(5, true), 1663680745), + price::new(i64::new(1508, false), 3, i64::new(5, true), 1663680745), + ), + ), + ], + }; + + let deserialized = deserialize(bytes, ctx(&mut test)); + + assert!(&expected == &deserialized, 1); + destroy(expected); + destroy(deserialized); + + test_scenario::end(test); + } +} diff --git a/target_chains/sui/contracts/sources/pyth.move b/target_chains/sui/contracts/sources/pyth.move new file mode 100644 index 0000000000..c8779aa927 --- /dev/null +++ b/target_chains/sui/contracts/sources/pyth.move @@ -0,0 +1 @@ +module pyth::pyth {} \ No newline at end of file diff --git a/target_chains/sui/contracts/sources/state.move b/target_chains/sui/contracts/sources/state.move new file mode 100644 index 0000000000..d51732171a --- /dev/null +++ b/target_chains/sui/contracts/sources/state.move @@ -0,0 +1,124 @@ +module pyth::state { + use std::vector; + use sui::object::{Self, UID}; + use sui::transfer::{Self}; + use sui::tx_context::{Self, TxContext}; + use sui::test_scenario::{Self}; + + //use pyth::price_identifier::PriceIdentifier; + use pyth::data_source::{Self, DataSource}; + //use pyth::price_info::PriceInfo; + + use wormhole::set::{Self, Set}; + use wormhole::external_address::{Self}; + + friend pyth::pyth; + + struct State has key { + id: UID, + // TODO - Make data_sources a dynamic field of State, + // inside of something embedded in State, because there will be + // 10k+ data sources in the future, and we want to minimize the + // size of State. + data_sources: Set, + governance_data_source: DataSource, + last_executed_governance_sequence: u64, + stale_price_threshold: u64, + base_update_fee: u64 + } + + // Initialization + public(friend) fun init_and_share_state( + stale_price_threshold: u64, + base_update_fee: u64, + governance_data_source: DataSource, + sources: vector, + ctx: &mut TxContext + ) { + // Convert the vector of DataSource objects into a set + // of DataSource objects + let data_sources = set::new(ctx); + while (!vector::is_empty(&sources)) { + set::add(&mut data_sources, vector::pop_back(&mut sources)); + }; + transfer::share_object( + State { + id: object::new(ctx), + data_sources, + governance_data_source, + last_executed_governance_sequence: 0, + stale_price_threshold, + base_update_fee + } + ); + } + + // Accessors + public fun get_stale_price_threshold_secs(s: &State): u64 { + s.stale_price_threshold + } + + public fun get_base_update_fee(s: &State): u64 { + s.base_update_fee + } + + public fun is_valid_data_source(s: &State, data_source: DataSource): bool { + set::contains(&s.data_sources, data_source) + } + + public fun is_valid_governance_data_source(s: &State, source: DataSource): bool { + s.governance_data_source == source + } + + public fun get_last_executed_governance_sequence(s: &State): u64 { + s.last_executed_governance_sequence + } + + // Setters + public(friend) fun set_data_sources(s: &mut State, new_sources: vector, ctx: &mut TxContext) { + //let sources = &mut s.data_sources; + let new_set = set::new(ctx); + //set::empty(sources); + while (!vector::is_empty(&new_sources)) { + set::add(&mut new_set, vector::pop_back(&mut new_sources)); + }; + s.data_sources = new_set; + } + + // public(friend) fun set_latest_price_info(price_identifier: PriceIdentifier, price_info: PriceInfo) acquires LatestPriceInfo { + // let latest_price_info = borrow_global_mut(@pyth); + // table::upsert(&mut latest_price_info.info, price_identifier, price_info) + // } + + // public(friend) fun set_last_executed_governance_sequence(sequence: u64) acquires LastExecutedGovernanceSequence { + // let last_executed_governance_sequence = borrow_global_mut(@pyth); + // last_executed_governance_sequence.sequence = sequence + // } + + // public(friend) fun pyth_signer(): signer acquires SignerCapability { + // account::create_signer_with_capability(&borrow_global(@pyth).signer_capability) + // } + + // public(friend) fun set_contract_upgrade_authorized_hash(hash: Hash) acquires ContractUpgradeAuthorized, SignerCapability { + // if (exists(@pyth)) { + // let ContractUpgradeAuthorized { hash: _ } = move_from(@pyth); + // }; + + // move_to(&pyth_signer(), ContractUpgradeAuthorized { hash }); + // } + + // public(friend) fun set_governance_data_source(source: DataSource) acquires GovernanceDataSource { + // let valid_governance_data_source = borrow_global_mut(@pyth); + // valid_governance_data_source.source = source; + // } + + // public(friend) fun set_base_update_fee(fee: u64) acquires BaseUpdateFee { + // let update_fee = borrow_global_mut(@pyth); + // update_fee.fee = fee + // } + + // public(friend) fun set_stale_price_threshold_secs(threshold_secs: u64) acquires StalePriceThreshold { + // let stale_price_threshold = borrow_global_mut(@pyth); + // stale_price_threshold.threshold_secs = threshold_secs + // } +} \ No newline at end of file From b69917ea3a63be63babfc17fafdd877e952e2a09 Mon Sep 17 00:00:00 2001 From: optke3 Date: Wed, 22 Mar 2023 17:36:53 +0000 Subject: [PATCH 2/9] finish state.move --- target_chains/sui/contracts/sources/set.move | 46 ++++++++++++ .../sui/contracts/sources/state.move | 70 ++++++------------- 2 files changed, 68 insertions(+), 48 deletions(-) create mode 100644 target_chains/sui/contracts/sources/set.move diff --git a/target_chains/sui/contracts/sources/set.move b/target_chains/sui/contracts/sources/set.move new file mode 100644 index 0000000000..b0f53549c8 --- /dev/null +++ b/target_chains/sui/contracts/sources/set.move @@ -0,0 +1,46 @@ +/// A set data structure. +module pyth::set { + use sui::table::{Self, Table}; + use sui::tx_context::{TxContext}; + use std::vector; + + /// Empty struct. Used as the value type in mappings to encode a set + struct Unit has store, copy, drop {} + + /// A set containing elements of type `A` with support for membership + /// checking. + struct Set has store { + keys: vector, + elems: Table + } + + /// Create a new Set. + public fun new(ctx: &mut TxContext): Set { + Set { + keys: vector::empty(), + elems: table::new(ctx), + } + } + + /// Add a new element to the set. + /// Aborts if the element already exists + public fun add(set: &mut Set, key: A) { + table::add(&mut set.elems, key, Unit {}); + vector::push_back(&mut set.keys, key); + } + + /// Returns true iff `set` contains an entry for `key`. + public fun contains(set: &Set, key: A): bool { + table::contains(&set.elems, key) + } + + /// Removes all elements from the set + public fun empty(set: &mut Set) { + while (!vector::is_empty(&set.keys)) { + table::remove(&mut set.elems, vector::pop_back(&mut set.keys)); + } + } + + // TODO: destroy_empty, but this is tricky because std::table doesn't + // have this functionality. +} diff --git a/target_chains/sui/contracts/sources/state.move b/target_chains/sui/contracts/sources/state.move index d51732171a..d0a3009d13 100644 --- a/target_chains/sui/contracts/sources/state.move +++ b/target_chains/sui/contracts/sources/state.move @@ -2,15 +2,10 @@ module pyth::state { use std::vector; use sui::object::{Self, UID}; use sui::transfer::{Self}; - use sui::tx_context::{Self, TxContext}; - use sui::test_scenario::{Self}; + use sui::tx_context::{TxContext}; - //use pyth::price_identifier::PriceIdentifier; - use pyth::data_source::{Self, DataSource}; - //use pyth::price_info::PriceInfo; - - use wormhole::set::{Self, Set}; - use wormhole::external_address::{Self}; + use pyth::data_source::{DataSource}; + use pyth::set::{Self, Set}; friend pyth::pyth; @@ -75,50 +70,29 @@ module pyth::state { } // Setters - public(friend) fun set_data_sources(s: &mut State, new_sources: vector, ctx: &mut TxContext) { - //let sources = &mut s.data_sources; - let new_set = set::new(ctx); - //set::empty(sources); + public(friend) fun set_data_sources(s: &mut State, new_sources: vector) { + // Empty the existing set of data sources instead of dropping it, + // because it does not have drop ability. + set::empty(&mut s.data_sources); + // Add new sources to state.data_sources. while (!vector::is_empty(&new_sources)) { - set::add(&mut new_set, vector::pop_back(&mut new_sources)); + set::add(&mut s.data_sources, vector::pop_back(&mut new_sources)); }; - s.data_sources = new_set; } - // public(friend) fun set_latest_price_info(price_identifier: PriceIdentifier, price_info: PriceInfo) acquires LatestPriceInfo { - // let latest_price_info = borrow_global_mut(@pyth); - // table::upsert(&mut latest_price_info.info, price_identifier, price_info) - // } - - // public(friend) fun set_last_executed_governance_sequence(sequence: u64) acquires LastExecutedGovernanceSequence { - // let last_executed_governance_sequence = borrow_global_mut(@pyth); - // last_executed_governance_sequence.sequence = sequence - // } - - // public(friend) fun pyth_signer(): signer acquires SignerCapability { - // account::create_signer_with_capability(&borrow_global(@pyth).signer_capability) - // } - - // public(friend) fun set_contract_upgrade_authorized_hash(hash: Hash) acquires ContractUpgradeAuthorized, SignerCapability { - // if (exists(@pyth)) { - // let ContractUpgradeAuthorized { hash: _ } = move_from(@pyth); - // }; - - // move_to(&pyth_signer(), ContractUpgradeAuthorized { hash }); - // } + public(friend) fun set_last_executed_governance_sequence(s: &mut State, sequence: u64) { + s.last_executed_governance_sequence = sequence; + } - // public(friend) fun set_governance_data_source(source: DataSource) acquires GovernanceDataSource { - // let valid_governance_data_source = borrow_global_mut(@pyth); - // valid_governance_data_source.source = source; - // } + public(friend) fun set_governance_data_source(s: &mut State, source: DataSource) { + s. governance_data_source = source; + } - // public(friend) fun set_base_update_fee(fee: u64) acquires BaseUpdateFee { - // let update_fee = borrow_global_mut(@pyth); - // update_fee.fee = fee - // } + public(friend) fun set_base_update_fee(s: &mut State, fee: u64) { + s.base_update_fee = fee; + } - // public(friend) fun set_stale_price_threshold_secs(threshold_secs: u64) acquires StalePriceThreshold { - // let stale_price_threshold = borrow_global_mut(@pyth); - // stale_price_threshold.threshold_secs = threshold_secs - // } -} \ No newline at end of file + public(friend) fun set_stale_price_threshold_secs(s: &mut State, threshold_secs: u64) { + s.stale_price_threshold = threshold_secs; + } +} From 0380a1f244b07d46e3271b9cad23a805eb1d65e8 Mon Sep 17 00:00:00 2001 From: optke3 Date: Wed, 22 Mar 2023 18:08:21 +0000 Subject: [PATCH 3/9] add new line to pyth --- target_chains/sui/contracts/sources/pyth.move | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/target_chains/sui/contracts/sources/pyth.move b/target_chains/sui/contracts/sources/pyth.move index c8779aa927..01d7493657 100644 --- a/target_chains/sui/contracts/sources/pyth.move +++ b/target_chains/sui/contracts/sources/pyth.move @@ -1 +1 @@ -module pyth::pyth {} \ No newline at end of file +module pyth::pyth {} From e799fc60c5ee3e08056723bf5dbe2aa1a9c91b2d Mon Sep 17 00:00:00 2001 From: optke3 Date: Wed, 22 Mar 2023 19:19:48 +0000 Subject: [PATCH 4/9] use deployer cap pattern for state module --- .../sui/contracts/sources/state.move | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/target_chains/sui/contracts/sources/state.move b/target_chains/sui/contracts/sources/state.move index d0a3009d13..7e1cd73640 100644 --- a/target_chains/sui/contracts/sources/state.move +++ b/target_chains/sui/contracts/sources/state.move @@ -2,13 +2,19 @@ module pyth::state { use std::vector; use sui::object::{Self, UID}; use sui::transfer::{Self}; - use sui::tx_context::{TxContext}; + use sui::tx_context::{Self, TxContext}; use pyth::data_source::{DataSource}; use pyth::set::{Self, Set}; friend pyth::pyth; + /// Capability for creating a bridge state object, granted to sender when this + /// module is deployed + struct DeployerCap has key, store { + id: UID + } + struct State has key { id: UID, // TODO - Make data_sources a dynamic field of State, @@ -22,14 +28,27 @@ module pyth::state { base_update_fee: u64 } + fun init(ctx: &mut TxContext) { + transfer::transfer( + DeployerCap { + id: object::new(ctx) + }, + tx_context::sender(ctx) + ); + } + // Initialization public(friend) fun init_and_share_state( + deployer: DeployerCap, stale_price_threshold: u64, base_update_fee: u64, governance_data_source: DataSource, sources: vector, ctx: &mut TxContext ) { + let DeployerCap { id } = deployer; + object::delete(id); + // Convert the vector of DataSource objects into a set // of DataSource objects let data_sources = set::new(ctx); From 9ac886050e256d9538a28c186a51a0981c5027fc Mon Sep 17 00:00:00 2001 From: optke3 Date: Wed, 22 Mar 2023 19:17:04 +0000 Subject: [PATCH 5/9] sui pyth --- target_chains/sui/contracts/sources/pyth.move | 57 ++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/target_chains/sui/contracts/sources/pyth.move b/target_chains/sui/contracts/sources/pyth.move index 01d7493657..7f60d433f7 100644 --- a/target_chains/sui/contracts/sources/pyth.move +++ b/target_chains/sui/contracts/sources/pyth.move @@ -1 +1,56 @@ -module pyth::pyth {} +module pyth::pyth { + use std::vector; + use sui::tx_context::{TxContext}; + + use pyth::data_source::{Self, DataSource}; + //use pyth::set::{Self}; + use pyth::state::{Self}; + + use wormhole::external_address::{Self}; + + struct PythInitializationEvent has copy, drop {} + + fun init( + stale_price_threshold: u64, + governance_emitter_chain_id: u64, + governance_emitter_address: vector, + data_sources_emitter_chain_ids: vector, + data_sources_emitter_addresses: vector>, + update_fee: u64, + ctx: &mut TxContext + ) { + state::init_and_share_state( + stale_price_threshold, + update_fee, + data_source::new( + governance_emitter_chain_id, + external_address::from_bytes(governance_emitter_address)), + parse_data_sources( + data_sources_emitter_chain_ids, + data_sources_emitter_addresses, + ), + ctx + ) + } + + fun parse_data_sources( + emitter_chain_ids: vector, + emitter_addresses: vector>): vector { + + // TODO - add custom error type error::data_source_emitter_address_and_chain_ids_different_lengths() + assert!(vector::length(&emitter_chain_ids) == vector::length(&emitter_addresses), 0); + + let sources = vector::empty(); + let i = 0; + while (i < vector::length(&emitter_chain_ids)) { + vector::push_back(&mut sources, data_source::new( + *vector::borrow(&emitter_chain_ids, i), + external_address::from_bytes(*vector::borrow(&emitter_addresses, i)) + )); + + i = i + 1; + }; + sources + } + +} From 05d2121189b71fc4ede1deabd1796ee4c99f9977 Mon Sep 17 00:00:00 2001 From: optke3 Date: Thu, 23 Mar 2023 19:39:07 +0000 Subject: [PATCH 6/9] update price feeds, dynamic object fields, Sui object PriceInfoObject --- .../sources/batch_price_attestation.move | 24 +-- .../sui/contracts/sources/data_source.move | 42 ++++ .../sui/contracts/sources/event.move | 33 +++ .../sui/contracts/sources/price_feed.move | 14 +- .../sui/contracts/sources/price_info.move | 95 ++++++++- target_chains/sui/contracts/sources/pyth.move | 196 +++++++++++++++++- .../sui/contracts/sources/state.move | 73 ++++--- 7 files changed, 424 insertions(+), 53 deletions(-) create mode 100644 target_chains/sui/contracts/sources/event.move diff --git a/target_chains/sui/contracts/sources/batch_price_attestation.move b/target_chains/sui/contracts/sources/batch_price_attestation.move index 5021535435..f7ccd00ae7 100644 --- a/target_chains/sui/contracts/sources/batch_price_attestation.move +++ b/target_chains/sui/contracts/sources/batch_price_attestation.move @@ -153,7 +153,7 @@ module pyth::batch_price_attestation { ema_timestamp = prev_publish_time; }; - price_info::new( + price_info::new_price_info( attestation_time, tx_context::epoch(ctx), //TODO - use Sui Clock to get timestamp in seconds price_feed::new( @@ -172,7 +172,7 @@ module pyth::batch_price_attestation { // A batch price attestation with a magic number of 0x50325749 let bytes = x"5032574900030000000102000400951436e0be37536be96f0896366089506a59763d036728332d3e3038047851aea7c6c75c89f14810ec1c54c03ab8f1864a4c4032791f05747f560faec380a695d1000000000000049a0000000000000008fffffffb00000000000005dc0000000000000003000000000100000001000000006329c0eb000000006329c0e9000000006329c0e400000000000006150000000000000007215258d81468614f6b7e194c5d145609394f67b041e93e6695dcc616faadd0603b9551a68d01d954d6387aff4df1529027ffb2fee413082e509feb29cc4904fe000000000000041a0000000000000003fffffffb00000000000005cb0000000000000003010000000100000001000000006329c0eb000000006329c0e9000000006329c0e4000000000000048600000000000000078ac9cf3ab299af710d735163726fdae0db8465280502eb9f801f74b3c1bd190333832fad6e36eb05a8972fe5f219b27b5b2bb2230a79ce79beb4c5c5e7ecc76d00000000000003f20000000000000002fffffffb00000000000005e70000000000000003010000000100000001000000006329c0eb000000006329c0e9000000006329c0e40000000000000685000000000000000861db714e9ff987b6fedf00d01f9fea6db7c30632d6fc83b7bc9459d7192bc44a21a28b4c6619968bd8c20e95b0aaed7df2187fd310275347e0376a2cd7427db800000000000006cb0000000000000001fffffffb00000000000005e40000000000000003010000000100000001000000006329c0eb000000006329c0e9000000006329c0e400000000000007970000000000000001"; - destroy(deserialize(bytes, ctx(&mut test))); + let _ = destroy(deserialize(bytes, ctx(&mut test))); test_scenario::end(test); } @@ -198,42 +198,38 @@ module pyth::batch_price_attestation { attestation_count: 4, attestation_size: 149, price_infos: vector[ - price_info::new( + price_info::new_price_info( 1663680747, arrival_time, price_feed::new( price_identifier::from_byte_vec(x"c6c75c89f14810ec1c54c03ab8f1864a4c4032791f05747f560faec380a695d1"), price::new(i64::new(1557, false), 7, i64::new(5, true), 1663680740), price::new(i64::new(1500, false), 3, i64::new(5, true), 1663680740), - ), - ), - price_info::new( + ) ), + price_info::new_price_info( 1663680747, arrival_time, price_feed::new( price_identifier::from_byte_vec(x"3b9551a68d01d954d6387aff4df1529027ffb2fee413082e509feb29cc4904fe"), price::new(i64::new(1050, false), 3, i64::new(5, true), 1663680745), price::new(i64::new(1483, false), 3, i64::new(5, true), 1663680745), - ), - ), - price_info::new( + ) ), + price_info::new_price_info( 1663680747, arrival_time, price_feed::new( price_identifier::from_byte_vec(x"33832fad6e36eb05a8972fe5f219b27b5b2bb2230a79ce79beb4c5c5e7ecc76d"), price::new(i64::new(1010, false), 2, i64::new(5, true), 1663680745), price::new(i64::new(1511, false), 3, i64::new(5, true), 1663680745), - ), - ), - price_info::new( + ) ), + price_info::new_price_info( 1663680747, arrival_time, price_feed::new( price_identifier::from_byte_vec(x"21a28b4c6619968bd8c20e95b0aaed7df2187fd310275347e0376a2cd7427db8"), price::new(i64::new(1739, false), 1, i64::new(5, true), 1663680745), price::new(i64::new(1508, false), 3, i64::new(5, true), 1663680745), - ), - ), + ) ), ], }; diff --git a/target_chains/sui/contracts/sources/data_source.move b/target_chains/sui/contracts/sources/data_source.move index c79e548f22..c8b7af8266 100644 --- a/target_chains/sui/contracts/sources/data_source.move +++ b/target_chains/sui/contracts/sources/data_source.move @@ -1,11 +1,53 @@ module pyth::data_source { + use sui::dynamic_field::{Self}; + use sui::object::{UID}; + use sui::tx_context::{TxContext}; + + use pyth::set::{Self}; + use wormhole::external_address::ExternalAddress; + const KEY: vector = b"data_sources"; + struct DataSource has copy, drop, store { emitter_chain: u64, emitter_address: ExternalAddress, } + public fun new_data_source_registry(parent_id: &mut UID, ctx: &mut TxContext) { + assert!( + !dynamic_field::exists_(parent_id, KEY), + 0 // TODO - add custom error type + ); + dynamic_field::add( + parent_id, + KEY, + set::new(ctx) + ) + } + + public fun add(parent_id: &mut UID, data_source: DataSource) { + assert!( + !contains(parent_id, data_source), + 0 // TODO - add custom error message + ); + set::add( + dynamic_field::borrow_mut(parent_id, KEY), + data_source + ) + } + + public fun empty(parent_id: &mut UID){ + set::empty( + dynamic_field::borrow_mut(parent_id, KEY) + ) + } + + public fun contains(parent_id: &UID, data_source: DataSource): bool { + let ref = dynamic_field::borrow(parent_id, KEY); + set::contains(ref, data_source) + } + public fun new(emitter_chain: u64, emitter_address: ExternalAddress): DataSource { DataSource { emitter_chain: emitter_chain, diff --git a/target_chains/sui/contracts/sources/event.move b/target_chains/sui/contracts/sources/event.move new file mode 100644 index 0000000000..fadb33d4fa --- /dev/null +++ b/target_chains/sui/contracts/sources/event.move @@ -0,0 +1,33 @@ +module pyth::event { + use sui::event::{Self}; + use pyth::price_feed::{PriceFeed}; + + friend pyth::pyth; + friend pyth::state; + + struct PythInitializationEvent has copy, drop {} + + /// Signifies that a price feed has been updated + struct PriceFeedUpdate has copy, store, drop { + /// Value of the price feed + price_feed: PriceFeed, + /// Timestamp of the update + timestamp: u64, + } + + public(friend) fun emit_price_feed_update(price_feed: PriceFeed, timestamp: u64) { + event::emit( + PriceFeedUpdate { + price_feed, + timestamp, + } + ); + } + + public(friend) fun emit_pyth_initialization_event() { + event::emit( + PythInitializationEvent {} + ); + } + +} diff --git a/target_chains/sui/contracts/sources/price_feed.move b/target_chains/sui/contracts/sources/price_feed.move index 01ae10ba59..43ba535552 100644 --- a/target_chains/sui/contracts/sources/price_feed.move +++ b/target_chains/sui/contracts/sources/price_feed.move @@ -23,8 +23,18 @@ module pyth::price_feed { } } - public fun get_price_identifier(price_feed: &PriceFeed): &PriceIdentifier { - &price_feed.price_identifier + public fun from( + price_feed: &PriceFeed + ): PriceFeed { + PriceFeed { + price_identifier: price_feed.price_identifier, + price: price_feed.price, + ema_price: price_feed.ema_price, + } + } + + public fun get_price_identifier(price_feed: &PriceFeed): PriceIdentifier { + price_feed.price_identifier } public fun get_price(price_feed: &PriceFeed): Price { diff --git a/target_chains/sui/contracts/sources/price_info.move b/target_chains/sui/contracts/sources/price_info.move index e533fc9bfd..d49695d30b 100644 --- a/target_chains/sui/contracts/sources/price_info.move +++ b/target_chains/sui/contracts/sources/price_info.move @@ -1,13 +1,76 @@ module pyth::price_info { - use pyth::price_feed::PriceFeed; + use sui::object::{Self, UID, ID}; + use sui::tx_context::{TxContext}; + use sui::dynamic_object_field::{Self}; + use sui::table::{Self}; + use pyth::price_feed::{Self, PriceFeed}; + use pyth::price_identifier::{PriceIdentifier}; + + const KEY: vector = b"price_info"; + + friend pyth::pyth; + + /// Sui Object version of PriceInfo. + /// Has a key and lives in global store. + struct PriceInfoObject has key, store { + id: UID, + price_info: PriceInfo + } + + /// Copyable and droppable. struct PriceInfo has copy, drop, store { attestation_time: u64, arrival_time: u64, price_feed: PriceFeed, } - public fun new(attestation_time: u64, arrival_time: u64, price_feed: PriceFeed): PriceInfo { + /// Creates a table which maps a PriceIdentifier to the + /// UID (in bytes) of the corresponding Sui PriceInfoObject. + public fun new_price_info_registry(parent_id: &mut UID, ctx: &mut TxContext) { + assert!( + !dynamic_object_field::exists_(parent_id, KEY), + 0 // TODO - add custom error message + ); + dynamic_object_field::add( + parent_id, + KEY, + table::new(ctx) + ) + } + + public fun add(parent_id: &mut UID, price_identifier: PriceIdentifier, id: ID) { + assert!( + !contains(parent_id, price_identifier), + 0 // TODO - add custom error message + ); + table::add( + dynamic_object_field::borrow_mut(parent_id, KEY), + price_identifier, + id + ) + } + + public fun contains(parent_id: &UID, price_identifier: PriceIdentifier): bool { + let ref = dynamic_object_field::borrow(parent_id, KEY); + table::contains(ref, price_identifier) + } + + public fun new_price_info_object( + price_info: PriceInfo, + ctx: &mut TxContext + ): PriceInfoObject { + PriceInfoObject { + id: object::new(ctx), + price_info: price_info + } + } + + public fun new_price_info( + attestation_time: u64, + arrival_time: u64, + price_feed: PriceFeed, + ): PriceInfo { PriceInfo { attestation_time: attestation_time, arrival_time: arrival_time, @@ -15,6 +78,27 @@ module pyth::price_info { } } + #[test_only] + public fun destroy(price_info: PriceInfoObject){ + let PriceInfoObject { + id: id, + price_info: _, + } = price_info; + object::delete(id); + } + + public fun uid_to_inner(price_info: &PriceInfoObject): ID { + object::uid_to_inner(&price_info.id) + } + + public fun get_price_info_from_price_info_object(price_info: &PriceInfoObject): PriceInfo { + price_info.price_info + } + + public fun get_price_info_price_identifier(price_info: &PriceInfo): PriceIdentifier { + price_feed::get_price_identifier(&price_info.price_feed) + } + public fun get_price_feed(price_info: &PriceInfo): &PriceFeed { &price_info.price_feed } @@ -26,4 +110,11 @@ module pyth::price_info { public fun get_arrival_time(price_info: &PriceInfo): u64 { price_info.arrival_time } + + public(friend) fun update_price_info_object( + price_info_object: &mut PriceInfoObject, + price_info: PriceInfo + ) { + price_info_object.price_info = price_info; + } } diff --git a/target_chains/sui/contracts/sources/pyth.move b/target_chains/sui/contracts/sources/pyth.move index 7f60d433f7..cfa2f67605 100644 --- a/target_chains/sui/contracts/sources/pyth.move +++ b/target_chains/sui/contracts/sources/pyth.move @@ -1,16 +1,28 @@ module pyth::pyth { use std::vector; use sui::tx_context::{TxContext}; + use sui::coin::{Coin}; + use sui::sui::{SUI}; + use sui::transfer::{Self}; + use sui::tx_context::{Self}; + use pyth::event::{Self as pyth_event}; use pyth::data_source::{Self, DataSource}; - //use pyth::set::{Self}; - use pyth::state::{Self}; + use pyth::state::{Self as state, State as PythState, DeployerCap}; + use pyth::price_info::{Self, PriceInfo, PriceInfoObject}; + use pyth::batch_price_attestation::{Self}; + use pyth::price_feed::{Self}; + use pyth::price::{Self}; use wormhole::external_address::{Self}; + use wormhole::vaa::{Self}; + use wormhole::state::{State as WormState}; - struct PythInitializationEvent has copy, drop {} - fun init( + /// Call init_and_share_state with deployer cap to initialize + /// state and emit event corresponding to Pyth initialization. + public entry fun init_pyth( + deployer: DeployerCap, stale_price_threshold: u64, governance_emitter_chain_id: u64, governance_emitter_address: vector, @@ -20,6 +32,7 @@ module pyth::pyth { ctx: &mut TxContext ) { state::init_and_share_state( + deployer, stale_price_threshold, update_fee, data_source::new( @@ -30,12 +43,16 @@ module pyth::pyth { data_sources_emitter_addresses, ), ctx - ) + ); + + // Emit Pyth initialization event. + pyth_event::emit_pyth_initialization_event(); } fun parse_data_sources( emitter_chain_ids: vector, - emitter_addresses: vector>): vector { + emitter_addresses: vector> + ): vector { // TODO - add custom error type error::data_source_emitter_address_and_chain_ids_different_lengths() assert!(vector::length(&emitter_chain_ids) == vector::length(&emitter_addresses), 0); @@ -53,4 +70,171 @@ module pyth::pyth { sources } + /// Create and share new price feed objects if they don't already exist. + public fun create_price_feeds( + worm_state: &mut WormState, + pyth_state: &PythState, + vaas: vector>, + ctx: &mut TxContext + ){ + while (!vector::is_empty(&vaas)) { + let vaa = vector::pop_back(&mut vaas); + + // Deserialize the VAA + let vaa = vaa::parse_and_verify(worm_state, vaa, ctx); + + // Check that the VAA is from a valid data source (emitter) + assert!( + state::is_valid_data_source( + pyth_state, + data_source::new( + (vaa::emitter_chain(&vaa) as u64), + vaa::emitter_address(&vaa)) + ), + 0); // TODO - use custom error message - error::invalid_data_source() + + // Deserialize the batch price attestation + let price_infos = batch_price_attestation::destroy(batch_price_attestation::deserialize(vaa::take_payload(vaa), ctx)); + while (!vector::is_empty(&price_infos)){ + let cur_price_info = vector::pop_back(&mut price_infos); + + // Only create new Sui PriceInfoObject if not already + // registered with the Pyth State object. + if (!state::price_feed_object_exists( + pyth_state, + price_feed::get_price_identifier( + price_info::get_price_feed(&cur_price_info) + ) + ) + ){ + // Create and share newly created Sui PriceInfoObject containing a price feed. + let new_price_info_object = price_info::new_price_info_object(cur_price_info, ctx); + transfer::share_object(new_price_info_object); + } + } + }; + } + + /// Update PriceInfo objects and corresponding price feeds with the + /// data in the given VAAs. + /// + /// The vaas argument is a vector of VAAs encoded as bytes. + /// + /// The javascript https://github.com/pyth-network/pyth-js/tree/main/pyth-aptos-js package + /// should be used to fetch these VAAs from the Price Service. More information about this + /// process can be found at https://docs.pyth.network/consume-data. + /// + /// The given fee must contain a sufficient number of coins to pay the update fee for the given vaas. + /// The update fee amount can be queried by calling get_update_fee(&vaas). + /// + /// Please read more information about the update fee here: https://docs.pyth.network/consume-data/on-demand#fees + public fun update_price_feeds( + worm_state: &WormState, + pyth_state: &PythState, + vaas: vector>, + price_info_objects: &mut vector, + fee: Coin, + ctx: &mut TxContext + ) { + // Charge the message update fee + // TODO - error::insufficient_fee() + //assert!(get_update_fee(&vaas) <= coin::value(&fee), 0); + transfer::transfer(fee, @pyth); + + // Update the price feed from each VAA + while (!vector::is_empty(&vaas)) { + update_price_feed_from_single_vaa( + worm_state, + pyth_state, + vector::pop_back(&mut vaas), + price_info_objects, + ctx + ); + }; + } + + /// Precondition: A Sui object of type PriceInfoObject must exist for each update + /// encoded in the worm_vaa (batch_attestation_vaa). These should be passed in + /// via the price_info_objects argument. + fun update_price_feed_from_single_vaa( + worm_state: &WormState, + pyth_state: &PythState, + worm_vaa: vector, + price_info_objects: &mut vector, + ctx: &mut TxContext + ) { + // Deserialize the VAA + let vaa = vaa::parse_and_verify(worm_state, worm_vaa, ctx); + + // Check that the VAA is from a valid data source (emitter) + assert!( + state::is_valid_data_source( + pyth_state, + data_source::new( + (vaa::emitter_chain(&vaa) as u64), + vaa::emitter_address(&vaa)) + ), + 0); // TODO - use custom error message - error::invalid_data_source() + + // Deserialize the batch price attestation + let price_infos = batch_price_attestation::destroy(batch_price_attestation::deserialize(vaa::take_payload(vaa), ctx)); + + // Update price info objects. + update_cache(price_infos, price_info_objects, ctx); + } + + /// Update PriceInfoObjects using up-to-date PriceInfos. + fun update_cache( + updates: vector, + price_info_objects: &mut vector, + ctx: &mut TxContext + ){ + while (!vector::is_empty(&updates)) { + let update = vector::pop_back(&mut updates); + let i = 0; + let found = false; + // Find PriceInfoObjects corresponding to the current update (PriceInfo). + // TODO - This for loop might be expensive if there are a large + // number of updates and/or price_info_objects we are updating. + while (i < vector::length(price_info_objects)){ + // Check if the current price info object corresponds to the price feed that + // the update is meant for. + let price_info = price_info::get_price_info_from_price_info_object(vector::borrow(price_info_objects, i)); + if (price_info::get_price_info_price_identifier(&price_info) == + price_info::get_price_info_price_identifier(&update)){ + found = true; + // TODO: use clock timestamp instead of epoch in the future + pyth_event::emit_price_feed_update(price_feed::from(price_info::get_price_feed(&update)), tx_context::epoch(ctx)); + + // Update the price info object with the new updated price info. + if (is_fresh_update(&update, vector::borrow(price_info_objects, i))){ + price_info::update_price_info_object( + vector::borrow_mut(price_info_objects, i), + update + ); + } + } + }; + if (!found){ + // TODO - throw error, since the price_feeds in price_info_objects do + // not constitute a superset of the price_feeds to be updated + } + }; + vector::destroy_empty(updates); + } + + /// Determine if the given price update is "fresh": we have nothing newer already cached for that + /// price feed within a PriceInfoObject. + fun is_fresh_update(update: &PriceInfo, price_info_object: &PriceInfoObject): bool { + // Get the timestamp of the update's current price + let price_feed = price_info::get_price_feed(update); + let update_timestamp = price::get_timestamp(&price_feed::get_price(price_feed)); + + // Get the timestamp of the cached data for the price identifier + let cached_price_info = price_info::get_price_info_from_price_info_object(price_info_object); + let cached_price_feed = price_info::get_price_feed(&cached_price_info); + let cached_timestamp = price::get_timestamp(&price_feed::get_price(cached_price_feed)); + + update_timestamp > cached_timestamp + } } diff --git a/target_chains/sui/contracts/sources/state.move b/target_chains/sui/contracts/sources/state.move index 7e1cd73640..637dd1e876 100644 --- a/target_chains/sui/contracts/sources/state.move +++ b/target_chains/sui/contracts/sources/state.move @@ -1,11 +1,12 @@ module pyth::state { use std::vector; - use sui::object::{Self, UID}; + use sui::object::{Self, UID, ID}; use sui::transfer::{Self}; use sui::tx_context::{Self, TxContext}; - use pyth::data_source::{DataSource}; - use pyth::set::{Self, Set}; + use pyth::data_source::{Self, DataSource}; + use pyth::price_info::{Self}; + use pyth::price_identifier::{PriceIdentifier}; friend pyth::pyth; @@ -17,15 +18,10 @@ module pyth::state { struct State has key { id: UID, - // TODO - Make data_sources a dynamic field of State, - // inside of something embedded in State, because there will be - // 10k+ data sources in the future, and we want to minimize the - // size of State. - data_sources: Set, governance_data_source: DataSource, last_executed_governance_sequence: u64, stale_price_threshold: u64, - base_update_fee: u64 + base_update_fee: u64, } fun init(ctx: &mut TxContext) { @@ -49,22 +45,34 @@ module pyth::state { let DeployerCap { id } = deployer; object::delete(id); - // Convert the vector of DataSource objects into a set - // of DataSource objects - let data_sources = set::new(ctx); + let uid = object::new(ctx); + + // Create a set that contains all registered data sources and + // attach it to uid as a dynamic field to minimize the + // size of State. + data_source::new_data_source_registry(&mut uid, ctx); + + // Create a table that tracks the object IDs of price feeds and + // attach it to the uid as a dynamic object field to minimize the + // size of State. + price_info::new_price_info_registry(&mut uid, ctx); + + // Iterate through data sources and add them to the data source + // registry in state. while (!vector::is_empty(&sources)) { - set::add(&mut data_sources, vector::pop_back(&mut sources)); + data_source::add(&mut uid, vector::pop_back(&mut sources)); }; - transfer::share_object( - State { - id: object::new(ctx), - data_sources, - governance_data_source, - last_executed_governance_sequence: 0, - stale_price_threshold, - base_update_fee - } - ); + + // Share state so that is a shared Sui object. + transfer::share_object( + State { + id: uid, + governance_data_source, + last_executed_governance_sequence: 0, + stale_price_threshold, + base_update_fee, + } + ); } // Accessors @@ -77,7 +85,7 @@ module pyth::state { } public fun is_valid_data_source(s: &State, data_source: DataSource): bool { - set::contains(&s.data_sources, data_source) + data_source::contains(&s.id, data_source) } public fun is_valid_governance_data_source(s: &State, source: DataSource): bool { @@ -88,14 +96,17 @@ module pyth::state { s.last_executed_governance_sequence } + public fun price_feed_object_exists(s: &State, p: PriceIdentifier): bool { + price_info::contains(&s.id, p) + } + // Setters public(friend) fun set_data_sources(s: &mut State, new_sources: vector) { - // Empty the existing set of data sources instead of dropping it, - // because it does not have drop ability. - set::empty(&mut s.data_sources); - // Add new sources to state.data_sources. + // Empty the existing table of data sources registered in state. + data_source::empty(&mut s.id); + // Add the new data sources to the dynamic field registry. while (!vector::is_empty(&new_sources)) { - set::add(&mut s.data_sources, vector::pop_back(&mut new_sources)); + data_source::add(&mut s.id, vector::pop_back(&mut new_sources)); }; } @@ -114,4 +125,8 @@ module pyth::state { public(friend) fun set_stale_price_threshold_secs(s: &mut State, threshold_secs: u64) { s.stale_price_threshold = threshold_secs; } + + public(friend) fun register_price_feed(s: &mut State, p: PriceIdentifier, id: ID){ + price_info::add(&mut s.id, p, id); + } } From 096f31ab7828b72b5cd723b306dec0b2d0840db1 Mon Sep 17 00:00:00 2001 From: optke3 Date: Thu, 23 Mar 2023 20:11:26 +0000 Subject: [PATCH 7/9] register price info object with pyth state after creation --- .../sui/contracts/sources/price_info.move | 2 +- target_chains/sui/contracts/sources/pyth.move | 16 +++++++++++----- target_chains/sui/contracts/sources/state.move | 4 ++++ 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/target_chains/sui/contracts/sources/price_info.move b/target_chains/sui/contracts/sources/price_info.move index d49695d30b..9da41d6ee2 100644 --- a/target_chains/sui/contracts/sources/price_info.move +++ b/target_chains/sui/contracts/sources/price_info.move @@ -95,7 +95,7 @@ module pyth::price_info { price_info.price_info } - public fun get_price_info_price_identifier(price_info: &PriceInfo): PriceIdentifier { + public fun get_price_identifier(price_info: &PriceInfo): PriceIdentifier { price_feed::get_price_identifier(&price_info.price_feed) } diff --git a/target_chains/sui/contracts/sources/pyth.move b/target_chains/sui/contracts/sources/pyth.move index cfa2f67605..d7666b407a 100644 --- a/target_chains/sui/contracts/sources/pyth.move +++ b/target_chains/sui/contracts/sources/pyth.move @@ -72,8 +72,8 @@ module pyth::pyth { /// Create and share new price feed objects if they don't already exist. public fun create_price_feeds( - worm_state: &mut WormState, - pyth_state: &PythState, + worm_state: &WormState, + pyth_state: &mut PythState, vaas: vector>, ctx: &mut TxContext ){ @@ -107,8 +107,14 @@ module pyth::pyth { ) ) ){ - // Create and share newly created Sui PriceInfoObject containing a price feed. + // Create and share newly created Sui PriceInfoObject containing a price feed, + // and then register a copy of its ID with State. let new_price_info_object = price_info::new_price_info_object(cur_price_info, ctx); + let price_identifier = price_info::get_price_identifier(&cur_price_info); + let id = price_info::uid_to_inner(&new_price_info_object); + + state::register_price_info_object(pyth_state, price_identifier, id); + transfer::share_object(new_price_info_object); } } @@ -200,8 +206,8 @@ module pyth::pyth { // Check if the current price info object corresponds to the price feed that // the update is meant for. let price_info = price_info::get_price_info_from_price_info_object(vector::borrow(price_info_objects, i)); - if (price_info::get_price_info_price_identifier(&price_info) == - price_info::get_price_info_price_identifier(&update)){ + if (price_info::get_price_identifier(&price_info) == + price_info::get_price_identifier(&update)){ found = true; // TODO: use clock timestamp instead of epoch in the future pyth_event::emit_price_feed_update(price_feed::from(price_info::get_price_feed(&update)), tx_context::epoch(ctx)); diff --git a/target_chains/sui/contracts/sources/state.move b/target_chains/sui/contracts/sources/state.move index 637dd1e876..ec6160e73a 100644 --- a/target_chains/sui/contracts/sources/state.move +++ b/target_chains/sui/contracts/sources/state.move @@ -110,6 +110,10 @@ module pyth::state { }; } + public(friend) fun register_price_info_object(s: &mut State, price_identifier: PriceIdentifier, id: ID) { + price_info::add(&mut s.id, price_identifier, id); + } + public(friend) fun set_last_executed_governance_sequence(s: &mut State, sequence: u64) { s.last_executed_governance_sequence = sequence; } From 690107d496cef24afc76185e16d8ceecac753c03 Mon Sep 17 00:00:00 2001 From: optke3 Date: Thu, 30 Mar 2023 18:40:34 +0000 Subject: [PATCH 8/9] sui governance --- target_chains/sui/contracts/Move.toml | 4 +- .../sources/governance/contract_upgrade.move | 12 +++ .../sources/governance/governance.move | 73 ++++++++++++++++ .../sources/governance/governance_action.move | 38 +++++++++ .../governance/governance_instruction.move | 84 +++++++++++++++++++ .../sources/governance/set_data_sources.move | 43 ++++++++++ .../set_governance_data_source.move | 36 ++++++++ .../governance/set_stale_price_threshold.move | 25 ++++++ .../sources/governance/set_update_fee.move | 40 +++++++++ target_chains/sui/contracts/sources/pyth.move | 4 +- .../sui/contracts/sources/state.move | 6 ++ 11 files changed, 361 insertions(+), 4 deletions(-) create mode 100644 target_chains/sui/contracts/sources/governance/contract_upgrade.move create mode 100644 target_chains/sui/contracts/sources/governance/governance.move create mode 100644 target_chains/sui/contracts/sources/governance/governance_action.move create mode 100644 target_chains/sui/contracts/sources/governance/governance_instruction.move create mode 100644 target_chains/sui/contracts/sources/governance/set_data_sources.move create mode 100644 target_chains/sui/contracts/sources/governance/set_governance_data_source.move create mode 100644 target_chains/sui/contracts/sources/governance/set_stale_price_threshold.move create mode 100644 target_chains/sui/contracts/sources/governance/set_update_fee.move diff --git a/target_chains/sui/contracts/Move.toml b/target_chains/sui/contracts/Move.toml index 7db7a62dd3..d1fc1dd52c 100644 --- a/target_chains/sui/contracts/Move.toml +++ b/target_chains/sui/contracts/Move.toml @@ -4,8 +4,8 @@ version = "0.0.1" [dependencies.Sui] git = "https://github.com/MystenLabs/sui.git" -subdir = "crates/sui-framework" -rev = "157ac72030d014f17d76cefe81f3915b4afab2c9" +subdir = "crates/sui-framework/packages/sui-framework" +rev = "82c9c80c11488858f1d3930f47ec9f335a566683" [dependencies.Wormhole] git = "https://github.com/wormhole-foundation/wormhole.git" diff --git a/target_chains/sui/contracts/sources/governance/contract_upgrade.move b/target_chains/sui/contracts/sources/governance/contract_upgrade.move new file mode 100644 index 0000000000..c2c010f8c6 --- /dev/null +++ b/target_chains/sui/contracts/sources/governance/contract_upgrade.move @@ -0,0 +1,12 @@ +module pyth::contract_upgrade { + use pyth::state::{State}; + + use wormhole::state::{State as WormState}; + + friend pyth::governance; + + /// Payload should be the bytes digest of the new contract. + public(friend) fun execute(_worm_state: &WormState, _pyth_state: &State, _payload: vector){ + // TODO + } +} \ No newline at end of file diff --git a/target_chains/sui/contracts/sources/governance/governance.move b/target_chains/sui/contracts/sources/governance/governance.move new file mode 100644 index 0000000000..40b26ad95a --- /dev/null +++ b/target_chains/sui/contracts/sources/governance/governance.move @@ -0,0 +1,73 @@ +module pyth::governance { + use sui::tx_context::{TxContext}; + + use pyth::data_source::{Self}; + use pyth::governance_instruction; + use pyth::governance_action; + use pyth::contract_upgrade; + use pyth::set_governance_data_source; + use pyth::set_data_sources; + use pyth::set_stale_price_threshold; + use pyth::state::{State}; + use pyth::set_update_fee; + use pyth::state; + + use wormhole::vaa::{Self, VAA}; + use wormhole::state::{State as WormState}; + + public entry fun execute_governance_instruction( + pyth_state : &mut State, + worm_state: &WormState, + vaa_bytes: vector, + ctx: &mut TxContext + ) { + let parsed_vaa = parse_and_verify_governance_vaa(pyth_state, worm_state, vaa_bytes, ctx); + let instruction = governance_instruction::from_byte_vec(vaa::take_payload(parsed_vaa)); + + // Dispatch the instruction to the appropiate handler + let action = governance_instruction::get_action(&instruction); + if (action == governance_action::new_contract_upgrade()) { + assert!(governance_instruction::get_target_chain_id(&instruction) != 0, + 0); // TODO - error::governance_contract_upgrade_chain_id_zero() + contract_upgrade::execute(worm_state, pyth_state, governance_instruction::destroy(instruction)); + } else if (action == governance_action::new_set_governance_data_source()) { + set_governance_data_source::execute(pyth_state, governance_instruction::destroy(instruction)); + } else if (action == governance_action::new_set_data_sources()) { + set_data_sources::execute(pyth_state, governance_instruction::destroy(instruction)); + } else if (action == governance_action::new_set_update_fee()) { + set_update_fee::execute(pyth_state, governance_instruction::destroy(instruction)); + } else if (action == governance_action::new_set_stale_price_threshold()) { + set_stale_price_threshold::execute(pyth_state, governance_instruction::destroy(instruction)); + } else { + governance_instruction::destroy(instruction); + assert!(false, 0); // TODO - error::invalid_governance_action() + } + } + + fun parse_and_verify_governance_vaa( + pyth_state: &mut State, + worm_state: &WormState, + bytes: vector, + ctx: &mut TxContext + ): VAA { + let parsed_vaa = vaa::parse_and_verify(worm_state, bytes, ctx); + + // Check that the governance data source is valid + assert!( + state::is_valid_governance_data_source( + pyth_state, + data_source::new( + (vaa::emitter_chain(&parsed_vaa) as u64), + vaa::emitter_address(&parsed_vaa))), + 0); // TODO - error::invalid_governance_data_source() + + // Check that the sequence number is greater than the last executed governance VAA + let sequence = vaa::sequence(&parsed_vaa); + assert!(sequence > state::get_last_executed_governance_sequence(pyth_state), 0); // TODO - error::invalid_governance_sequence_number() + state::set_last_executed_governance_sequence(pyth_state, sequence); + + parsed_vaa + } +} + +// TODO - add tests \ No newline at end of file diff --git a/target_chains/sui/contracts/sources/governance/governance_action.move b/target_chains/sui/contracts/sources/governance/governance_action.move new file mode 100644 index 0000000000..0426083364 --- /dev/null +++ b/target_chains/sui/contracts/sources/governance/governance_action.move @@ -0,0 +1,38 @@ +module pyth::governance_action { + //use pyth::error; + + const CONTRACT_UPGRADE: u8 = 0; + const SET_GOVERNANCE_DATA_SOURCE: u8 = 1; + const SET_DATA_SOURCES: u8 = 2; + const SET_UPDATE_FEE: u8 = 3; + const SET_STALE_PRICE_THRESHOLD: u8 = 4; + + struct GovernanceAction has copy, drop { + value: u8, + } + + public fun from_u8(value: u8): GovernanceAction { + assert!(CONTRACT_UPGRADE <= value && value <= SET_STALE_PRICE_THRESHOLD, 0); //TODO - add specific error: error::invalid_governance_action() + GovernanceAction { value } + } + + public fun new_contract_upgrade(): GovernanceAction { + GovernanceAction { value: CONTRACT_UPGRADE } + } + + public fun new_set_governance_data_source(): GovernanceAction { + GovernanceAction { value: SET_GOVERNANCE_DATA_SOURCE } + } + + public fun new_set_data_sources(): GovernanceAction { + GovernanceAction { value: SET_DATA_SOURCES } + } + + public fun new_set_update_fee(): GovernanceAction { + GovernanceAction { value: SET_UPDATE_FEE } + } + + public fun new_set_stale_price_threshold(): GovernanceAction { + GovernanceAction { value: SET_STALE_PRICE_THRESHOLD } + } +} diff --git a/target_chains/sui/contracts/sources/governance/governance_instruction.move b/target_chains/sui/contracts/sources/governance/governance_instruction.move new file mode 100644 index 0000000000..841e28addd --- /dev/null +++ b/target_chains/sui/contracts/sources/governance/governance_instruction.move @@ -0,0 +1,84 @@ +module pyth::governance_instruction { + use wormhole::cursor; + use pyth::deserialize; + use pyth::governance_action::{Self, GovernanceAction}; + + const MAGIC: vector = x"5054474d"; // "PTGM": Pyth Governance Message + const MODULE: u8 = 1; + + struct GovernanceInstruction { + module_: u8, + action: GovernanceAction, + target_chain_id: u64, + payload: vector, + } + + fun validate(instruction: &GovernanceInstruction) { + assert!(instruction.module_ == MODULE, 0); // TODO - add custom error::invalid_governance_module() + let target_chain_id = instruction.target_chain_id; + assert!(target_chain_id == (wormhole::state::chain_id() as u64) || target_chain_id == 0, 0); // TODO - custom error: error::invalid_governance_target_chain_id() + } + + public fun from_byte_vec(bytes: vector): GovernanceInstruction { + let cursor = cursor::new(bytes); + let magic = deserialize::deserialize_vector(&mut cursor, 4); + assert!(magic == MAGIC, 0); // TODO error::invalid_governance_magic_value() + let module_ = deserialize::deserialize_u8(&mut cursor); + let action = governance_action::from_u8(deserialize::deserialize_u8(&mut cursor)); + let target_chain_id = deserialize::deserialize_u16(&mut cursor); + let payload = cursor::take_rest(cursor); + + let instruction = GovernanceInstruction { + module_, + action, + target_chain_id : (target_chain_id as u64), + payload + }; + validate(&instruction); + + instruction + } + + public fun get_module(instruction: &GovernanceInstruction): u8 { + instruction.module_ + } + + public fun get_action(instruction: &GovernanceInstruction): GovernanceAction { + instruction.action + } + + public fun get_target_chain_id(instruction: &GovernanceInstruction): u64 { + instruction.target_chain_id + } + + public fun destroy(instruction: GovernanceInstruction): vector { + let GovernanceInstruction { + module_: _, + action: _, + target_chain_id: _, + payload: payload + } = instruction; + payload + } + + #[test] + #[expected_failure] + fun test_from_byte_vec_invalid_magic() { + let bytes = x"5054474eb01087a85361f738f19454e66664d3c9"; + destroy(from_byte_vec(bytes)); + } + + #[test] + #[expected_failure] + fun test_from_byte_vec_invalid_module() { + let bytes = x"5054474db00187a85361f738f19454e66664d3c9"; + destroy(from_byte_vec(bytes)); + } + + #[test] + #[expected_failure] + fun test_from_byte_vec_invalid_target_chain_id() { + let bytes = x"5054474db00187a85361f738f19454e66664d3c9"; + destroy(from_byte_vec(bytes)); + } +} diff --git a/target_chains/sui/contracts/sources/governance/set_data_sources.move b/target_chains/sui/contracts/sources/governance/set_data_sources.move new file mode 100644 index 0000000000..b1d442592b --- /dev/null +++ b/target_chains/sui/contracts/sources/governance/set_data_sources.move @@ -0,0 +1,43 @@ +module pyth::set_data_sources { + use std::vector; + + use wormhole::cursor; + use wormhole::external_address::{Self}; + + use pyth::deserialize; + use pyth::data_source::{Self, DataSource}; + use pyth::state::{Self, State}; + + friend pyth::governance; + + struct SetDataSources { + sources: vector, + } + + public(friend) fun execute(state: &mut State, payload: vector) { + let SetDataSources { sources } = from_byte_vec(payload); + state::set_data_sources(state, sources); + } + + fun from_byte_vec(bytes: vector): SetDataSources { + let cursor = cursor::new(bytes); + let data_sources_count = deserialize::deserialize_u8(&mut cursor); + + let sources = vector::empty(); + + let i = 0; + while (i < data_sources_count) { + let emitter_chain_id = deserialize::deserialize_u16(&mut cursor); + let emitter_address = external_address::from_bytes(deserialize::deserialize_vector(&mut cursor, 32)); + vector::push_back(&mut sources, data_source::new((emitter_chain_id as u64), emitter_address)); + + i = i + 1; + }; + + cursor::destroy_empty(cursor); + + SetDataSources { + sources + } + } +} diff --git a/target_chains/sui/contracts/sources/governance/set_governance_data_source.move b/target_chains/sui/contracts/sources/governance/set_governance_data_source.move new file mode 100644 index 0000000000..77328a8788 --- /dev/null +++ b/target_chains/sui/contracts/sources/governance/set_governance_data_source.move @@ -0,0 +1,36 @@ +module pyth::set_governance_data_source { + use pyth::deserialize; + use pyth::data_source; + use pyth::state::{Self, State}; + + use wormhole::cursor; + use wormhole::external_address::{Self, ExternalAddress}; + //use wormhole::state::{Self} + + friend pyth::governance; + + struct SetGovernanceDataSource { + emitter_chain_id: u64, + emitter_address: ExternalAddress, + initial_sequence: u64, + } + + public(friend) fun execute(pyth_state: &mut State, payload: vector) { + let SetGovernanceDataSource { emitter_chain_id, emitter_address, initial_sequence } = from_byte_vec(payload); + state::set_governance_data_source(pyth_state, data_source::new(emitter_chain_id, emitter_address)); + state::set_last_executed_governance_sequence(pyth_state, initial_sequence); + } + + fun from_byte_vec(bytes: vector): SetGovernanceDataSource { + let cursor = cursor::new(bytes); + let emitter_chain_id = deserialize::deserialize_u16(&mut cursor); + let emitter_address = external_address::from_bytes(deserialize::deserialize_vector(&mut cursor, 32)); + let initial_sequence = deserialize::deserialize_u64(&mut cursor); + cursor::destroy_empty(cursor); + SetGovernanceDataSource { + emitter_chain_id: (emitter_chain_id as u64), + emitter_address, + initial_sequence + } + } +} diff --git a/target_chains/sui/contracts/sources/governance/set_stale_price_threshold.move b/target_chains/sui/contracts/sources/governance/set_stale_price_threshold.move new file mode 100644 index 0000000000..6fdb6a2ed0 --- /dev/null +++ b/target_chains/sui/contracts/sources/governance/set_stale_price_threshold.move @@ -0,0 +1,25 @@ +module pyth::set_stale_price_threshold { + use wormhole::cursor; + use pyth::deserialize; + use pyth::state::{Self, State}; + + friend pyth::governance; + + struct SetStalePriceThreshold { + threshold: u64, + } + + public(friend) fun execute(state: &mut State, payload: vector) { + let SetStalePriceThreshold { threshold } = from_byte_vec(payload); + state::set_stale_price_threshold_secs(state, threshold); + } + + fun from_byte_vec(bytes: vector): SetStalePriceThreshold { + let cursor = cursor::new(bytes); + let threshold = deserialize::deserialize_u64(&mut cursor); + cursor::destroy_empty(cursor); + SetStalePriceThreshold { + threshold + } + } +} diff --git a/target_chains/sui/contracts/sources/governance/set_update_fee.move b/target_chains/sui/contracts/sources/governance/set_update_fee.move new file mode 100644 index 0000000000..c300a69855 --- /dev/null +++ b/target_chains/sui/contracts/sources/governance/set_update_fee.move @@ -0,0 +1,40 @@ +module pyth::set_update_fee { + use sui::math::{Self}; + + use pyth::deserialize; + use pyth::state::{Self, State}; + + use wormhole::cursor; + + + friend pyth::governance; + + const MAX_U64: u128 = (1 << 64) - 1; + + struct SetUpdateFee { + mantissa: u64, + exponent: u64, + } + + public(friend) fun execute(pyth_state: &mut State, payload: vector) { + let SetUpdateFee { mantissa, exponent } = from_byte_vec(payload); + assert!(exponent <= 255, 0); // TODO - throw error that exponent does not fit in a u8 + let fee = apply_exponent(mantissa, (exponent as u8)); + state::set_base_update_fee(pyth_state, fee); + } + + fun from_byte_vec(bytes: vector): SetUpdateFee { + let cursor = cursor::new(bytes); + let mantissa = deserialize::deserialize_u64(&mut cursor); + let exponent = deserialize::deserialize_u64(&mut cursor); + cursor::destroy_empty(cursor); + SetUpdateFee { + mantissa, + exponent, + } + } + + fun apply_exponent(mantissa: u64, exponent: u8): u64 { + mantissa * math::pow(10, exponent) + } +} diff --git a/target_chains/sui/contracts/sources/pyth.move b/target_chains/sui/contracts/sources/pyth.move index d7666b407a..c368bbc1ae 100644 --- a/target_chains/sui/contracts/sources/pyth.move +++ b/target_chains/sui/contracts/sources/pyth.move @@ -115,7 +115,7 @@ module pyth::pyth { state::register_price_info_object(pyth_state, price_identifier, id); - transfer::share_object(new_price_info_object); + transfer::public_share_object(new_price_info_object); } } }; @@ -145,7 +145,7 @@ module pyth::pyth { // Charge the message update fee // TODO - error::insufficient_fee() //assert!(get_update_fee(&vaas) <= coin::value(&fee), 0); - transfer::transfer(fee, @pyth); + transfer::public_transfer(fee, @pyth); // Update the price feed from each VAA while (!vector::is_empty(&vaas)) { diff --git a/target_chains/sui/contracts/sources/state.move b/target_chains/sui/contracts/sources/state.move index ec6160e73a..dca62a116e 100644 --- a/target_chains/sui/contracts/sources/state.move +++ b/target_chains/sui/contracts/sources/state.move @@ -9,6 +9,12 @@ module pyth::state { use pyth::price_identifier::{PriceIdentifier}; friend pyth::pyth; + friend pyth::governance_action; + friend pyth::set_update_fee; + friend pyth::set_stale_price_threshold; + friend pyth::set_data_sources; + friend pyth::governance; + friend pyth::set_governance_data_source; /// Capability for creating a bridge state object, granted to sender when this /// module is deployed From 7966b572e69f1ed94fa1fdf224de1c7d318e34ed Mon Sep 17 00:00:00 2001 From: optke3 Date: Thu, 30 Mar 2023 18:48:14 +0000 Subject: [PATCH 9/9] some newlines --- .../sui/contracts/sources/governance/contract_upgrade.move | 2 +- target_chains/sui/contracts/sources/governance/governance.move | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/target_chains/sui/contracts/sources/governance/contract_upgrade.move b/target_chains/sui/contracts/sources/governance/contract_upgrade.move index c2c010f8c6..e0130c1304 100644 --- a/target_chains/sui/contracts/sources/governance/contract_upgrade.move +++ b/target_chains/sui/contracts/sources/governance/contract_upgrade.move @@ -9,4 +9,4 @@ module pyth::contract_upgrade { public(friend) fun execute(_worm_state: &WormState, _pyth_state: &State, _payload: vector){ // TODO } -} \ No newline at end of file +} diff --git a/target_chains/sui/contracts/sources/governance/governance.move b/target_chains/sui/contracts/sources/governance/governance.move index 40b26ad95a..bdebad43e3 100644 --- a/target_chains/sui/contracts/sources/governance/governance.move +++ b/target_chains/sui/contracts/sources/governance/governance.move @@ -70,4 +70,4 @@ module pyth::governance { } } -// TODO - add tests \ No newline at end of file +// TODO - add tests