diff --git a/lazer/contracts/sui/.gitignore b/lazer/contracts/sui/.gitignore new file mode 100644 index 0000000000..60b32c83a2 --- /dev/null +++ b/lazer/contracts/sui/.gitignore @@ -0,0 +1,3 @@ +build/* +.trace +.coverage* diff --git a/lazer/contracts/sui/Move.lock b/lazer/contracts/sui/Move.lock new file mode 100644 index 0000000000..d08a0eb8d4 --- /dev/null +++ b/lazer/contracts/sui/Move.lock @@ -0,0 +1,48 @@ +# @generated by Move, please check-in and do not edit manually. + +[move] +version = 3 +manifest_digest = "DD0B86B0E012F788977D2224EA46B39395FCF48AB7DAE200E70E6E12F9445868" +deps_digest = "F9B494B64F0615AED0E98FC12A85B85ECD2BC5185C22D30E7F67786BB52E507C" +dependencies = [ + { id = "Bridge", name = "Bridge" }, + { id = "MoveStdlib", name = "MoveStdlib" }, + { id = "Sui", name = "Sui" }, + { id = "SuiSystem", name = "SuiSystem" }, +] + +[[move.package]] +id = "Bridge" +source = { git = "https://github.com/MystenLabs/sui.git", rev = "b448b1d971bd6c1aac8ef4eee4305943806d5d5b", subdir = "crates/sui-framework/packages/bridge" } + +dependencies = [ + { id = "MoveStdlib", name = "MoveStdlib" }, + { id = "Sui", name = "Sui" }, + { id = "SuiSystem", name = "SuiSystem" }, +] + +[[move.package]] +id = "MoveStdlib" +source = { git = "https://github.com/MystenLabs/sui.git", rev = "b448b1d971bd6c1aac8ef4eee4305943806d5d5b", subdir = "crates/sui-framework/packages/move-stdlib" } + +[[move.package]] +id = "Sui" +source = { git = "https://github.com/MystenLabs/sui.git", rev = "b448b1d971bd6c1aac8ef4eee4305943806d5d5b", subdir = "crates/sui-framework/packages/sui-framework" } + +dependencies = [ + { id = "MoveStdlib", name = "MoveStdlib" }, +] + +[[move.package]] +id = "SuiSystem" +source = { git = "https://github.com/MystenLabs/sui.git", rev = "b448b1d971bd6c1aac8ef4eee4305943806d5d5b", subdir = "crates/sui-framework/packages/sui-system" } + +dependencies = [ + { id = "MoveStdlib", name = "MoveStdlib" }, + { id = "Sui", name = "Sui" }, +] + +[move.toolchain-version] +compiler-version = "1.53.2" +edition = "2024.beta" +flavor = "sui" diff --git a/lazer/contracts/sui/Move.toml b/lazer/contracts/sui/Move.toml new file mode 100644 index 0000000000..ae1617355c --- /dev/null +++ b/lazer/contracts/sui/Move.toml @@ -0,0 +1,12 @@ +[package] +name = "pyth_lazer" +edition = "2024.beta" + +[dependencies] + +[addresses] +pyth_lazer = "0x0" + +[dev-dependencies] + +[dev-addresses] diff --git a/lazer/contracts/sui/README.md b/lazer/contracts/sui/README.md new file mode 100644 index 0000000000..28c83546d0 --- /dev/null +++ b/lazer/contracts/sui/README.md @@ -0,0 +1,25 @@ +## Pyth Lazer Sui Contract + +`pyth_lazer` is a Sui package that allows consumers to easily parse and verify cryptographically signed price feed data from the Pyth Network's high-frequency Lazer protocol for use on-chain. + +This package is built using the Move language and Sui framework. + +### Build, test, deploy + +Install Sui CLI and build the project: + +```shell +brew install sui +sui move build +``` + +Run tests: + +```shell +sui move test +sui move test test_parse_and_verify_le_ecdsa_update # run a specific test +``` + +Deploy: + +TODO diff --git a/lazer/contracts/sui/sources/channel.move b/lazer/contracts/sui/sources/channel.move new file mode 100644 index 0000000000..891f364a23 --- /dev/null +++ b/lazer/contracts/sui/sources/channel.move @@ -0,0 +1,69 @@ +module pyth_lazer::channel; + +public enum Channel has copy, drop { + Invalid, + RealTime, + FixedRate50ms, + FixedRate200ms, +} + +/// Create a new Invalid channel +public fun new_invalid(): Channel { + Channel::Invalid +} + +/// Create a new RealTime channel +public fun new_real_time(): Channel { + Channel::RealTime +} + +/// Create a new FixedRate50ms channel +public fun new_fixed_rate_50ms(): Channel { + Channel::FixedRate50ms +} + +/// Create a new FixedRate200ms channel +public fun new_fixed_rate_200ms(): Channel { + Channel::FixedRate200ms +} + +/// Check if the channel is Invalid +public fun is_invalid(channel: &Channel): bool { + match (channel) { + Channel::Invalid => true, + _ => false, + } +} + +/// Check if the channel is RealTime +public fun is_real_time(channel: &Channel): bool { + match (channel) { + Channel::RealTime => true, + _ => false, + } +} + +/// Check if the channel is FixedRate50ms +public fun is_fixed_rate_50ms(channel: &Channel): bool { + match (channel) { + Channel::FixedRate50ms => true, + _ => false, + } +} + +/// Check if the channel is FixedRate200ms +public fun is_fixed_rate_200ms(channel: &Channel): bool { + match (channel) { + Channel::FixedRate200ms => true, + _ => false, + } +} + +/// Get the update interval in milliseconds for fixed rate channels, returns 0 for non-fixed rate channels +public fun get_update_interval_ms(channel: &Channel): u64 { + match (channel) { + Channel::FixedRate50ms => 50, + Channel::FixedRate200ms => 200, + _ => 0, + } +} \ No newline at end of file diff --git a/lazer/contracts/sui/sources/feed.move b/lazer/contracts/sui/sources/feed.move new file mode 100644 index 0000000000..d8b8d5e1d7 --- /dev/null +++ b/lazer/contracts/sui/sources/feed.move @@ -0,0 +1,147 @@ +module pyth_lazer::feed; + +use pyth_lazer::i16::I16; +use pyth_lazer::i64::I64; + +/// The feed struct is based on the Lazer rust protocol definition defined here: +/// https://github.com/pyth-network/pyth-crosschain/blob/main/lazer/sdk/rust/protocol/src/types.rs#L10 +/// +/// Some fields in Lazer are optional, as in Lazer might return None for them due to some conditions (for example, +/// not having enough publishers to calculate the price) and that is why they are represented as Option>. +/// The first Option is for the existence of the field within the update data and the second Option is for the +/// value of the field. +public struct Feed has copy, drop { + /// Unique identifier for the price feed (e.g., 1 for BTC/USD, 2 for ETH/USD) + feed_id: u32, + /// Current aggregate price from all publishers + price: Option>, + /// Best bid price available across all publishers + best_bid_price: Option>, + /// Best ask price available across all publishers + best_ask_price: Option>, + /// Number of publishers contributing to this price feed + publisher_count: Option, + /// Price exponent (typically negative, e.g., -8 means divide price by 10^8) + exponent: Option, + /// Confidence interval representing price uncertainty + confidence: Option>, + /// Funding rate for derivative products (e.g., perpetual futures) + funding_rate: Option>, + /// Timestamp when the funding rate was last updated + funding_timestamp: Option>, +} + +/// Create a new Feed with the specified parameters +public fun new( + feed_id: u32, + price: Option>, + best_bid_price: Option>, + best_ask_price: Option>, + publisher_count: Option, + exponent: Option, + confidence: Option>, + funding_rate: Option>, + funding_timestamp: Option>, +): Feed { + Feed { + feed_id, + price, + best_bid_price, + best_ask_price, + publisher_count, + exponent, + confidence, + funding_rate, + funding_timestamp, + } +} + +/// Get the feed ID +public fun feed_id(feed: &Feed): u32 { + feed.feed_id +} + +/// Get the price +public fun price(feed: &Feed): Option> { + feed.price +} + +/// Get the best bid price +public fun best_bid_price(feed: &Feed): Option> { + feed.best_bid_price +} + +/// Get the best ask price +public fun best_ask_price(feed: &Feed): Option> { + feed.best_ask_price +} + +/// Get the publisher count +public fun publisher_count(feed: &Feed): Option { + feed.publisher_count +} + +/// Get the exponent +public fun exponent(feed: &Feed): Option { + feed.exponent +} + +/// Get the confidence interval +public fun confidence(feed: &Feed): Option> { + feed.confidence +} + +/// Get the funding rate +public fun funding_rate(feed: &Feed): Option> { + feed.funding_rate +} + +/// Get the funding timestamp +public fun funding_timestamp(feed: &Feed): Option> { + feed.funding_timestamp +} + +/// Set the feed ID +public(package) fun set_feed_id(feed: &mut Feed, feed_id: u32) { + feed.feed_id = feed_id; +} + +/// Set the price +public(package) fun set_price(feed: &mut Feed, price: Option>) { + feed.price = price; +} + +/// Set the best bid price +public(package) fun set_best_bid_price(feed: &mut Feed, best_bid_price: Option>) { + feed.best_bid_price = best_bid_price; +} + +/// Set the best ask price +public(package) fun set_best_ask_price(feed: &mut Feed, best_ask_price: Option>) { + feed.best_ask_price = best_ask_price; +} + +/// Set the publisher count +public(package) fun set_publisher_count(feed: &mut Feed, publisher_count: Option) { + feed.publisher_count = publisher_count; +} + +/// Set the exponent +public(package) fun set_exponent(feed: &mut Feed, exponent: Option) { + feed.exponent = exponent; +} + +/// Set the confidence interval +public(package) fun set_confidence(feed: &mut Feed, confidence: Option>) { + feed.confidence = confidence; +} + +/// Set the funding rate +public(package) fun set_funding_rate(feed: &mut Feed, funding_rate: Option>) { + feed.funding_rate = funding_rate; +} + +/// Set the funding timestamp +public(package) fun set_funding_timestamp(feed: &mut Feed, funding_timestamp: Option>) { + feed.funding_timestamp = funding_timestamp; +} diff --git a/lazer/contracts/sui/sources/i16.move b/lazer/contracts/sui/sources/i16.move new file mode 100644 index 0000000000..5e9122652b --- /dev/null +++ b/lazer/contracts/sui/sources/i16.move @@ -0,0 +1,150 @@ +/// Adopted from pyth::i64, adapted for i16 + +module pyth_lazer::i16; + +const MAX_POSITIVE_MAGNITUDE: u64 = (1 << 15) - 1; // 32767 +const MAX_NEGATIVE_MAGNITUDE: u64 = (1 << 15); // 32768 + +/// To consume these values, first call `get_is_negative()` to determine if the I16 +/// represents a negative or positive value. Then call `get_magnitude_if_positive()` or +/// `get_magnitude_if_negative()` to get the magnitude of the number in unsigned u64 format. +/// This API forces consumers to handle positive and negative numbers safely. +public struct I16 has copy, drop, store { + negative: bool, + magnitude: u64, +} + +public fun new(magnitude: u64, mut negative: bool): I16 { + let mut max_magnitude = MAX_POSITIVE_MAGNITUDE; + if (negative) { + max_magnitude = MAX_NEGATIVE_MAGNITUDE; + }; + assert!(magnitude <= max_magnitude, 0); //error::magnitude_too_large() + + // Ensure we have a single zero representation: (0, false). + // (0, true) is invalid. + if (magnitude == 0) { + negative = false; + }; + + I16 { + magnitude, + negative, + } +} + +public fun get_is_negative(i: &I16): bool { + i.negative +} + +public fun get_magnitude_if_positive(in: &I16): u64 { + assert!(!in.negative, 0); // error::negative_value() + in.magnitude +} + +public fun get_magnitude_if_negative(in: &I16): u64 { + assert!(in.negative, 0); //error::positive_value() + in.magnitude +} + +public fun from_u16(from: u16): I16 { + // Use the MSB to determine whether the number is negative or not. + let from_u64 = (from as u64); + let negative = (from_u64 >> 15) == 1; + let magnitude = parse_magnitude(from_u64, negative); + + new(magnitude, negative) +} + +fun parse_magnitude(from: u64, negative: bool): u64 { + // If positive, then return the input verbatim + if (!negative) { + return from + }; + + // Otherwise convert from two's complement by inverting and adding 1 + // For 16-bit numbers, we only invert the lower 16 bits + let inverted = from ^ 0xFFFF; + inverted + 1 +} + +#[test] +fun test_max_positive_magnitude() { + new(0x7FFF, false); // 32767 + assert!(&new((1<<15) - 1, false) == &from_u16(((1<<15) - 1) as u16), 1); +} + +#[test] +#[expected_failure] +fun test_magnitude_too_large_positive() { + new(0x8000, false); // 32768 +} + +#[test] +fun test_max_negative_magnitude() { + new(0x8000, true); // 32768 + assert!(&new(1<<15, true) == &from_u16((1<<15) as u16), 1); +} + +#[test] +#[expected_failure] +fun test_magnitude_too_large_negative() { + new(0x8001, true); // 32769 +} + +#[test] +fun test_from_u16_positive() { + assert!(from_u16(0x1234) == new(0x1234, false), 1); +} + +#[test] +fun test_from_u16_negative() { + assert!(from_u16(0xEDCC) == new(0x1234, true), 1); +} + +#[test] +fun test_get_is_negative() { + assert!(get_is_negative(&new(234, true)) == true, 1); + assert!(get_is_negative(&new(767, false)) == false, 1); +} + +#[test] +fun test_get_magnitude_if_positive_positive() { + assert!(get_magnitude_if_positive(&new(7686, false)) == 7686, 1); +} + +#[test] +#[expected_failure] +fun test_get_magnitude_if_positive_negative() { + assert!(get_magnitude_if_positive(&new(7686, true)) == 7686, 1); +} + +#[test] +fun test_get_magnitude_if_negative_negative() { + assert!(get_magnitude_if_negative(&new(7686, true)) == 7686, 1); +} + +#[test] +#[expected_failure] +fun test_get_magnitude_if_negative_positive() { + assert!(get_magnitude_if_negative(&new(7686, false)) == 7686, 1); +} + +#[test] +fun test_single_zero_representation() { + assert!(&new(0, true) == &new(0, false), 1); + assert!(&new(0, true) == &from_u16(0), 1); + assert!(&new(0, false) == &from_u16(0), 1); +} + +#[test] +fun test_boundary_values() { + // Test positive boundary + assert!(from_u16(0x7FFF) == new(32767, false), 1); + + // Test negative boundary + assert!(from_u16(0x8000) == new(32768, true), 1); + + // Test -1 + assert!(from_u16(0xFFFF) == new(1, true), 1); +} \ No newline at end of file diff --git a/lazer/contracts/sui/sources/i64.move b/lazer/contracts/sui/sources/i64.move new file mode 100644 index 0000000000..3c6f0d0f8d --- /dev/null +++ b/lazer/contracts/sui/sources/i64.move @@ -0,0 +1,137 @@ +/// Adopted from pyth::i64 + +module pyth_lazer::i64; + +const MAX_POSITIVE_MAGNITUDE: u64 = (1 << 63) - 1; +const MAX_NEGATIVE_MAGNITUDE: u64 = (1 << 63); + +/// To consume these values, first call `get_is_negative()` to determine if the I64 +/// represents a negative or positive value. Then call `get_magnitude_if_positive()` or +/// `get_magnitude_if_negative()` to get the magnitude of the number in unsigned u64 format. +/// This API forces consumers to handle positive and negative numbers safely. +public struct I64 has copy, drop, store { + negative: bool, + magnitude: u64, +} + +public fun new(magnitude: u64, mut negative: bool): I64 { + let mut max_magnitude = MAX_POSITIVE_MAGNITUDE; + if (negative) { + max_magnitude = MAX_NEGATIVE_MAGNITUDE; + }; + assert!(magnitude <= max_magnitude, 0); //error::magnitude_too_large() + + + // Ensure we have a single zero representation: (0, false). + // (0, true) is invalid. + if (magnitude == 0) { + negative = false; + }; + + I64 { + magnitude, + negative, + } +} + +public fun get_is_negative(i: &I64): bool { + i.negative +} + +public fun get_magnitude_if_positive(in: &I64): u64 { + assert!(!in.negative, 0); // error::negative_value() + in.magnitude +} + +public fun get_magnitude_if_negative(in: &I64): u64 { + assert!(in.negative, 0); //error::positive_value() + in.magnitude +} + +public fun from_u64(from: u64): I64 { + // Use the MSB to determine whether the number is negative or not. + let negative = (from >> 63) == 1; + let magnitude = parse_magnitude(from, negative); + + new(magnitude, negative) +} + +fun parse_magnitude(from: u64, negative: bool): u64 { + // If positive, then return the input verbatamin + if (!negative) { + return from + }; + + // Otherwise convert from two's complement by inverting and adding 1 + let inverted = from ^ 0xFFFFFFFFFFFFFFFF; + inverted + 1 +} + +#[test] +fun test_max_positive_magnitude() { + new(0x7FFFFFFFFFFFFFFF, false); + assert!(&new(1<<63 - 1, false) == &from_u64(1<<63 - 1), 1); +} + +#[test] +#[expected_failure] +fun test_magnitude_too_large_positive() { + new(0x8000000000000000, false); +} + +#[test] +fun test_max_negative_magnitude() { + new(0x8000000000000000, true); + assert!(&new(1<<63, true) == &from_u64(1<<63), 1); +} + +#[test] +#[expected_failure] +fun test_magnitude_too_large_negative() { + new(0x8000000000000001, true); +} + +#[test] +fun test_from_u64_positive() { + assert!(from_u64(0x64673) == new(0x64673, false), 1); +} + +#[test] +fun test_from_u64_negative() { + assert!(from_u64(0xFFFFFFFFFFFEDC73) == new(0x1238D, true), 1); +} + +#[test] +fun test_get_is_negative() { + assert!(get_is_negative(&new(234, true)) == true, 1); + assert!(get_is_negative(&new(767, false)) == false, 1); +} + +#[test] +fun test_get_magnitude_if_positive_positive() { + assert!(get_magnitude_if_positive(&new(7686, false)) == 7686, 1); +} + +#[test] +#[expected_failure] +fun test_get_magnitude_if_positive_negative() { + assert!(get_magnitude_if_positive(&new(7686, true)) == 7686, 1); +} + +#[test] +fun test_get_magnitude_if_negative_negative() { + assert!(get_magnitude_if_negative(&new(7686, true)) == 7686, 1); +} + +#[test] +#[expected_failure] +fun test_get_magnitude_if_negative_positive() { + assert!(get_magnitude_if_negative(&new(7686, false)) == 7686, 1); +} + +#[test] +fun test_single_zero_representation() { + assert!(&new(0, true) == &new(0, false), 1); + assert!(&new(0, true) == &from_u64(0), 1); + assert!(&new(0, false) == &from_u64(0), 1); +} \ No newline at end of file diff --git a/lazer/contracts/sui/sources/pyth_lazer.move b/lazer/contracts/sui/sources/pyth_lazer.move new file mode 100644 index 0000000000..e07d96165b --- /dev/null +++ b/lazer/contracts/sui/sources/pyth_lazer.move @@ -0,0 +1,166 @@ +module pyth_lazer::pyth_lazer; + +use pyth_lazer::i16::Self; +use pyth_lazer::i64::Self; +use pyth_lazer::update::{Self, Update}; +use pyth_lazer::feed::{Self, Feed}; +use pyth_lazer::channel::Self; +use sui::bcs; +use sui::ecdsa_k1::secp256k1_ecrecover; + +const SECP256K1_SIG_LEN: u32 = 65; +const UPDATE_MESSAGE_MAGIC: u32 = 1296547300; +const PAYLOAD_MAGIC: u32 = 2479346549; + + +// TODO: +// initializer +// administration -> admin cap, upgrade cap, governance? +// storage module -> trusted signers, update fee?, treasury? +// error handling +// standalone verify signature function + +/// Parse the Lazer update message and validate the signature. +/// +/// The parsing logic is based on the Lazer rust protocol definition defined here: +/// https://github.com/pyth-network/pyth-crosschain/tree/main/lazer/sdk/rust/protocol +public fun parse_and_verify_le_ecdsa_update(update: vector): Update { + let mut cursor = bcs::new(update); + + let magic = cursor.peel_u32(); + assert!(magic == UPDATE_MESSAGE_MAGIC, 0); + + let mut signature = vector::empty(); + + let mut sig_i = 0; + while (sig_i < SECP256K1_SIG_LEN) { + signature.push_back(cursor.peel_u8()); + sig_i = sig_i + 1; + }; + + let payload_len = cursor.peel_u16(); + + let payload = cursor.into_remainder_bytes(); + + assert!((payload_len as u64) == payload.length(), 0); + + // 0 stands for keccak256 hash + let pubkey = secp256k1_ecrecover(&signature, &payload, 0); + + // Lazer signer pubkey + // FIXME: validate against trusted signer set in storage + assert!(pubkey == x"03a4380f01136eb2640f90c17e1e319e02bbafbeef2e6e67dc48af53f9827e155b", 0); + + let mut cursor = bcs::new(payload); + let payload_magic = cursor.peel_u32(); + assert!(payload_magic == PAYLOAD_MAGIC, 0); + + let timestamp = cursor.peel_u64(); + let channel_value = cursor.peel_u8(); + let channel = if (channel_value == 0) { + channel::new_invalid() + } else if (channel_value == 1) { + channel::new_real_time() + } else if (channel_value == 2) { + channel::new_fixed_rate_50ms() + } else if (channel_value == 3) { + channel::new_fixed_rate_200ms() + } else { + channel::new_invalid() // Default to Invalid for unknown values + }; + + let mut feeds = vector::empty(); + let mut feed_i = 0; + + let feed_count = cursor.peel_u8(); + + while (feed_i < feed_count) { + let feed_id = cursor.peel_u32(); + let mut feed = feed::new( + feed_id, + option::none(), + option::none(), + option::none(), + option::none(), + option::none(), + option::none(), + option::none(), + option::none() + ); + + let properties_count = cursor.peel_u8(); + let mut properties_i = 0; + + while (properties_i < properties_count) { + let property_id = cursor.peel_u8(); + + if (property_id == 0) { + let price = cursor.peel_u64(); + if (price != 0) { + feed.set_price(option::some(option::some(i64::from_u64(price)))); + } else { + feed.set_price(option::some(option::none())); + } + } else if (property_id == 1) { + let best_bid_price = cursor.peel_u64(); + if (best_bid_price != 0) { + feed.set_best_bid_price(option::some(option::some(i64::from_u64(best_bid_price)))); + } else { + feed.set_best_bid_price(option::some(option::none())); + } + } else if (property_id == 2) { + let best_ask_price = cursor.peel_u64(); + if (best_ask_price != 0) { + feed.set_best_ask_price(option::some(option::some(i64::from_u64(best_ask_price)))); + } else { + feed.set_best_ask_price(option::some(option::none())); + } + } else if (property_id == 3) { + let publisher_count = cursor.peel_u16(); + feed.set_publisher_count(option::some(publisher_count)); + } else if (property_id == 4) { + let exponent = cursor.peel_u16(); + feed.set_exponent(option::some(i16::from_u16(exponent))); + } else if (property_id == 5) { + let confidence = cursor.peel_u64(); + if (confidence != 0) { + feed.set_confidence(option::some(option::some(i64::from_u64(confidence)))); + } else { + feed.set_confidence(option::some(option::none())); + } + } else if (property_id == 6) { + let exists = cursor.peel_u8(); + if (exists == 1) { + let funding_rate = cursor.peel_u64(); + feed.set_funding_rate(option::some(option::some(i64::from_u64(funding_rate)))); + } else { + feed.set_funding_rate(option::some(option::none())); + } + } else if (property_id == 7) { + let exists = cursor.peel_u8(); + + if (exists == 1) { + let funding_timestamp = cursor.peel_u64(); + feed.set_funding_timestamp(option::some(option::some(funding_timestamp))); + } else { + feed.set_funding_timestamp(option::some(option::none())); + } + } else { + // When we have an unknown property, we do not know its length, and therefore + // we cannot ignore it and parse the next properties. + abort 0 // FIXME: return more granular error messages + }; + + properties_i = properties_i + 1; + }; + + vector::push_back(&mut feeds, feed); + + feed_i = feed_i + 1; + }; + + let remaining_bytes = cursor.into_remainder_bytes(); + assert!(remaining_bytes.length() == 0, 0); + + update::new(timestamp, channel, feeds) +} diff --git a/lazer/contracts/sui/sources/update.move b/lazer/contracts/sui/sources/update.move new file mode 100644 index 0000000000..5b43e90182 --- /dev/null +++ b/lazer/contracts/sui/sources/update.move @@ -0,0 +1,38 @@ +module pyth_lazer::update; + +use pyth_lazer::channel::Channel; +use pyth_lazer::feed::Feed; + +// public struct UpdateObject has key, store { +// id: UID, +// update: Update +// } + +public struct Update has copy, drop { + timestamp: u64, + channel: Channel, + feeds: vector, +} + +public fun new( + timestamp: u64, + channel: Channel, + feeds: vector +): Update { + Update { timestamp, channel, feeds } +} + +/// Get the timestamp of the update +public fun timestamp(update: &Update): u64 { + update.timestamp +} + +/// Get a reference to the channel of the update +public fun channel(update: &Update): Channel { + update.channel +} + +/// Get a reference to the feeds vector of the update +public fun feeds(update: &Update): vector { + update.feeds +} \ No newline at end of file diff --git a/lazer/contracts/sui/tests/pyth_lazer_tests.move b/lazer/contracts/sui/tests/pyth_lazer_tests.move new file mode 100644 index 0000000000..d7d30ea029 --- /dev/null +++ b/lazer/contracts/sui/tests/pyth_lazer_tests.move @@ -0,0 +1,94 @@ +#[test_only] +module pyth_lazer::pyth_lazer_tests; +use pyth_lazer::pyth_lazer::parse_and_verify_le_ecdsa_update; +use pyth_lazer::channel::new_fixed_rate_200ms; +use pyth_lazer::i16::{Self}; +use pyth_lazer::i64::{Self}; + +#[test] +public fun test_parse_and_verify_le_ecdsa_update() { + /* + The test data is from the Lazer subscription: + > Request + {"subscriptionId": 1, "type": "subscribe", "priceFeedIds": [1, 2, 112], "properties": ["price", "bestBidPrice", "bestAskPrice", "exponent", "fundingRate", "fundingTimestamp"], "chains": ["leEcdsa"], "channel": "fixed_rate@200ms", "jsonBinaryEncoding": "hex"} + < Response + { + "type": "streamUpdated", + "subscriptionId": 1, + "parsed": { + "timestampUs": "1753787555800000", + "priceFeeds": [ + { + "priceFeedId": 1, + "price": "11838353875029", + "bestBidPrice": "11838047151903", + "bestAskPrice": "11839270720540", + "exponent": -8 + }, + { + "priceFeedId": 2, + "price": "382538699314", + "bestBidPrice": "382520831095", + "bestAskPrice": "382561500067", + "exponent": -8 + }, + { + "priceFeedId": 112, + "price": "118856300000000000", + "exponent": -12, + "fundingRate": 100000000, + "fundingTimestamp": 1753776000000000 + } + ] + }, + "leEcdsa": { + "encoding": "hex", + "data": "e4bd474daafa101a7cdc2f4af22f5735aa3278f7161ae15efa9eac3851ca437e322fde467c9475497e1297499344826fe1209f6de234dce35bdfab8bf6b073be12a07cb201930075d3c793c063467c0f3b0600030301000000060055a0e054c40a0000011f679842c40a0000021c94868bc40a000004f8ff0600070002000000060032521511590000000177ac04105900000002a33b71125900000004f8ff060007007000000006000038d1d42c43a60101000000000000000002000000000000000004f4ff060100e1f50500000000070100e07ecb0c3b0600" + } + } + */ + + let hex_message = + x"e4bd474daafa101a7cdc2f4af22f5735aa3278f7161ae15efa9eac3851ca437e322fde467c9475497e1297499344826fe1209f6de234dce35bdfab8bf6b073be12a07cb201930075d3c793c063467c0f3b0600030301000000060055a0e054c40a0000011f679842c40a0000021c94868bc40a000004f8ff0600070002000000060032521511590000000177ac04105900000002a33b71125900000004f8ff060007007000000006000038d1d42c43a60101000000000000000002000000000000000004f4ff060100e1f50500000000070100e07ecb0c3b0600"; + + let update = parse_and_verify_le_ecdsa_update(hex_message); + + // If we reach this point, the function worked correctly + // (no assertion failures in parse_and_validate_update) + assert!(update.timestamp() == 1753787555800000, 0); + assert!(update.channel() == new_fixed_rate_200ms(), 0); + assert!(vector::length(&update.feeds()) == 3, 0); + + let feed_1 = vector::borrow(&update.feeds(), 0); + assert!(feed_1.feed_id() == 1, 0); + assert!(feed_1.price() == option::some(option::some(i64::from_u64(11838353875029))), 0); + assert!(feed_1.best_bid_price() == option::some(option::some(i64::from_u64(11838047151903))), 0); + assert!(feed_1.best_ask_price() == option::some(option::some(i64::from_u64(11839270720540))), 0); + assert!(feed_1.exponent() == option::some(i16::new(8, true)), 0); + assert!(feed_1.publisher_count() == option::none(), 0); + assert!(feed_1.confidence() == option::none(), 0); + assert!(feed_1.funding_rate() == option::some(option::none()), 0); + assert!(feed_1.funding_timestamp() == option::some(option::none()), 0); + + let feed_2 = vector::borrow(&update.feeds(), 1); + assert!(feed_2.feed_id() == 2, 0); + assert!(feed_2.price() == option::some(option::some(i64::from_u64(382538699314))), 0); + assert!(feed_2.best_bid_price() == option::some(option::some(i64::from_u64(382520831095))), 0); + assert!(feed_2.best_ask_price() == option::some(option::some(i64::from_u64(382561500067))), 0); + assert!(feed_2.exponent() == option::some(i16::new(8, true)), 0); + assert!(feed_2.publisher_count() == option::none(), 0); + assert!(feed_2.confidence() == option::none(), 0); + assert!(feed_2.funding_rate() == option::some(option::none()), 0); + assert!(feed_2.funding_timestamp() == option::some(option::none()), 0); + + let feed_3 = vector::borrow(&update.feeds(), 2); + assert!(feed_3.feed_id() == 112, 0); + assert!(feed_3.price() == option::some(option::some(i64::from_u64(118856300000000000))), 0); + assert!(feed_3.best_bid_price() == option::some(option::none()), 0); + assert!(feed_3.best_ask_price() == option::some(option::none()), 0); + assert!(feed_3.exponent() == option::some(i16::new(12, true)), 0); + assert!(feed_3.publisher_count() == option::none(), 0); + assert!(feed_3.confidence() == option::none(), 0); + assert!(feed_3.funding_rate() == option::some(option::some(i64::from_u64(100000000))), 0); + assert!(feed_3.funding_timestamp() == option::some(option::some(1753776000000000)), 0); +}