Преглед изворни кода

feat(lazer/sui): initialize (#2964)

* feat(lazer/sui): init lazer sui contract

* feat(lazer/sui): init structs and basic example

* feat(lazer/sui): add setters, ctors

* feat(lazer/sui): readme, cleanup

* fix(lazer/sui): make setters package private

* fix(lazer/sui): add funding_rate_interval, address review comments

* fix(lazer/sui): lock down ctors

* test(lazer/sui): fix
Tejas Badadare пре 3 месеци
родитељ
комит
3f35fc1792

+ 3 - 0
lazer/contracts/sui/.gitignore

@@ -0,0 +1,3 @@
+build/*
+.trace
+.coverage*

+ 48 - 0
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"

+ 12 - 0
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]

+ 25 - 0
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

+ 69 - 0
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,
+    }
+}

+ 161 - 0
lazer/contracts/sui/sources/feed.move

@@ -0,0 +1,161 @@
+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/payload.rs
+///
+/// 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<Option<T>>.
+/// The first Option<T> is for the existence of the field within the update data and the second Option<T> 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<Option<I64>>,
+    /// Best bid price available across all publishers
+    best_bid_price: Option<Option<I64>>,
+    /// Best ask price available across all publishers
+    best_ask_price: Option<Option<I64>>,
+    /// Number of publishers contributing to this price feed
+    publisher_count: Option<u16>,
+    /// Price exponent (typically negative, e.g., -8 means divide price by 10^8)
+    exponent: Option<I16>,
+    /// Confidence interval representing price uncertainty
+    confidence: Option<Option<I64>>,
+    /// Funding rate for derivative products (e.g., perpetual futures)
+    funding_rate: Option<Option<I64>>,
+    /// Timestamp when the funding rate was last updated
+    funding_timestamp: Option<Option<u64>>,
+    /// How often the funding rate and funding payments are calculated, in microseconds
+    funding_rate_interval: Option<Option<u64>>,
+}
+
+/// Create a new Feed with the specified parameters
+public(package) fun new(
+    feed_id: u32,
+    price: Option<Option<I64>>,
+    best_bid_price: Option<Option<I64>>,
+    best_ask_price: Option<Option<I64>>,
+    publisher_count: Option<u16>,
+    exponent: Option<I16>,
+    confidence: Option<Option<I64>>,
+    funding_rate: Option<Option<I64>>,
+    funding_timestamp: Option<Option<u64>>,
+    funding_rate_interval: Option<Option<u64>>,
+): Feed {
+    Feed {
+        feed_id,
+        price,
+        best_bid_price,
+        best_ask_price,
+        publisher_count,
+        exponent,
+        confidence,
+        funding_rate,
+        funding_timestamp,
+        funding_rate_interval
+    }
+}
+
+/// Get the feed ID
+public fun feed_id(feed: &Feed): u32 {
+    feed.feed_id
+}
+
+/// Get the price
+public fun price(feed: &Feed): Option<Option<I64>> {
+    feed.price
+}
+
+/// Get the best bid price
+public fun best_bid_price(feed: &Feed): Option<Option<I64>> {
+    feed.best_bid_price
+}
+
+/// Get the best ask price
+public fun best_ask_price(feed: &Feed): Option<Option<I64>> {
+    feed.best_ask_price
+}
+
+/// Get the publisher count
+public fun publisher_count(feed: &Feed): Option<u16> {
+    feed.publisher_count
+}
+
+/// Get the exponent
+public fun exponent(feed: &Feed): Option<I16> {
+    feed.exponent
+}
+
+/// Get the confidence interval
+public fun confidence(feed: &Feed): Option<Option<I64>> {
+    feed.confidence
+}
+
+/// Get the funding rate
+public fun funding_rate(feed: &Feed): Option<Option<I64>> {
+    feed.funding_rate
+}
+
+/// Get the funding timestamp
+public fun funding_timestamp(feed: &Feed): Option<Option<u64>> {
+    feed.funding_timestamp
+}
+
+/// Get the funding rate interval
+public fun funding_rate_interval(feed: &Feed): Option<Option<u64>> {
+    feed.funding_rate_interval
+}
+
+/// 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<Option<I64>>) {
+    feed.price = price;
+}
+
+/// Set the best bid price
+public(package) fun set_best_bid_price(feed: &mut Feed, best_bid_price: Option<Option<I64>>) {
+    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<Option<I64>>) {
+    feed.best_ask_price = best_ask_price;
+}
+
+/// Set the publisher count
+public(package) fun set_publisher_count(feed: &mut Feed, publisher_count: Option<u16>) {
+    feed.publisher_count = publisher_count;
+}
+
+/// Set the exponent
+public(package) fun set_exponent(feed: &mut Feed, exponent: Option<I16>) {
+    feed.exponent = exponent;
+}
+
+/// Set the confidence interval
+public(package) fun set_confidence(feed: &mut Feed, confidence: Option<Option<I64>>) {
+    feed.confidence = confidence;
+}
+
+/// Set the funding rate
+public(package) fun set_funding_rate(feed: &mut Feed, funding_rate: Option<Option<I64>>) {
+    feed.funding_rate = funding_rate;
+}
+
+/// Set the funding timestamp
+public(package) fun set_funding_timestamp(feed: &mut Feed, funding_timestamp: Option<Option<u64>>) {
+    feed.funding_timestamp = funding_timestamp;
+}
+
+/// Set the funding rate interval
+public(package) fun set_funding_rate_interval(feed: &mut Feed, funding_rate_interval: Option<Option<u64>>) {
+    feed.funding_rate_interval = funding_rate_interval;
+}

+ 150 - 0
lazer/contracts/sui/sources/i16.move

@@ -0,0 +1,150 @@
+/// Adapted from pyth::i64, modified 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);
+}

+ 137 - 0
lazer/contracts/sui/sources/i64.move

@@ -0,0 +1,137 @@
+/// Adapted 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);
+}

+ 176 - 0
lazer/contracts/sui/sources/pyth_lazer.move

@@ -0,0 +1,176 @@
+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<u8>): Update {
+    let mut cursor = bcs::new(update);
+
+    let magic = cursor.peel_u32();
+    assert!(magic == UPDATE_MESSAGE_MAGIC, 0);
+
+    let mut signature = vector::empty<u8>();
+
+    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<Feed>();
+    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(),
+            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 if (property_id == 8) {
+                let exists = cursor.peel_u8();
+
+                if (exists == 1) {
+                    let funding_rate_interval = cursor.peel_u64();
+                    feed.set_funding_rate_interval(option::some(option::some(funding_rate_interval)));
+                } else {
+                    feed.set_funding_rate_interval(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)
+}

+ 33 - 0
lazer/contracts/sui/sources/update.move

@@ -0,0 +1,33 @@
+module pyth_lazer::update;
+
+use pyth_lazer::channel::Channel;
+use pyth_lazer::feed::Feed;
+
+public struct Update has copy, drop {
+    timestamp: u64,
+    channel: Channel,
+    feeds: vector<Feed>,
+}
+
+public(package) fun new(
+    timestamp: u64,
+    channel: Channel,
+    feeds: vector<Feed>
+): 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<Feed> {
+    update.feeds
+}

+ 98 - 0
lazer/contracts/sui/tests/pyth_lazer_tests.move

@@ -0,0 +1,98 @@
+#[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", "fundingRateInterval"], "chains": ["leEcdsa"], "channel": "fixed_rate@200ms", "jsonBinaryEncoding": "hex"}
+    < Response
+    {
+        "type": "streamUpdated",
+        "subscriptionId": 1,
+        "parsed": {
+            "timestampUs": "1755625313400000",
+            "priceFeeds": [
+                {
+                    "priceFeedId": 1,
+                    "price": "11350721594969",
+                    "bestBidPrice": "11350696257890",
+                    "bestAskPrice": "11350868428965",
+                    "exponent": -8
+                },
+                {
+                    "priceFeedId": 2,
+                    "price": "417775510136",
+                    "bestBidPrice": "417771266475",
+                    "bestAskPrice": "417782074042",
+                    "exponent": -8
+                },
+                {
+                    "priceFeedId": 112,
+                    "price": "113747064619385816",
+                    "exponent": -12,
+                    "fundingRate": 31670000,
+                    "fundingTimestamp": 1755619200000000,
+                    "fundingRateInterval": 28800000000
+                }
+            ]
+        },
+        "leEcdsa": {
+            "encoding": "hex",
+            "data": "e4bd474d42e3c9c3477b30f2c5527ebe2fb2c8adadadacaddfa7d95243b80fb8f0d813b453e587f140cf40a1120d75f1ffee8ad4337267e4fcbd23eabb2a555804f85ec101a10075d3c793c0f4295fbb3c060003030100000007005986bacb520a00000162e937ca520a000002a5087bd4520a000004f8ff06000700080002000000070078625c456100000001aba11b456100000002ba8ac0456100000004f8ff060007000800700000000700d8c3e1445a1c940101000000000000000002000000000000000004f4ff0601f03ee30100000000070100e0c6f2b93c0600080100209db406000000"
+        }
+    }
+    */
+
+    let hex_message =
+        x"e4bd474d42e3c9c3477b30f2c5527ebe2fb2c8adadadacaddfa7d95243b80fb8f0d813b453e587f140cf40a1120d75f1ffee8ad4337267e4fcbd23eabb2a555804f85ec101a10075d3c793c0f4295fbb3c060003030100000007005986bacb520a00000162e937ca520a000002a5087bd4520a000004f8ff06000700080002000000070078625c456100000001aba11b456100000002ba8ac0456100000004f8ff060007000800700000000700d8c3e1445a1c940101000000000000000002000000000000000004f4ff0601f03ee30100000000070100e0c6f2b93c0600080100209db406000000";
+
+    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() == 1755625313400000, 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(11350721594969))), 0);
+    assert!(feed_1.best_bid_price() == option::some(option::some(i64::from_u64(11350696257890))), 0);
+    assert!(feed_1.best_ask_price() == option::some(option::some(i64::from_u64(11350868428965))), 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);
+    assert!(feed_1.funding_rate_interval() == 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(417775510136))), 0);
+    assert!(feed_2.best_bid_price() == option::some(option::some(i64::from_u64(417771266475))), 0);
+    assert!(feed_2.best_ask_price() == option::some(option::some(i64::from_u64(417782074042))), 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);
+    assert!(feed_2.funding_rate_interval() == 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(113747064619385816))), 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(31670000))), 0);
+    assert!(feed_3.funding_timestamp() == option::some(option::some(1755619200000000)), 0);
+    assert!(feed_3.funding_rate_interval() == option::some(option::some(28800000000)), 0);
+}