Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
2c4ae46
feat(lazer/sui): specify version
tejasbadadare Aug 19, 2025
69682bc
feat(lazer-sui): add state module with trusted signer storage and tests
devin-ai-integration[bot] Aug 19, 2025
21d8937
Merge branch 'tb/lazer/sui-storage' of github.com:pyth-network/pyth-c…
tejasbadadare Aug 19, 2025
7380293
fix
devin-ai-integration[bot] Aug 19, 2025
0e70796
Merge branch 'tb/lazer/sui-storage' of github.com:pyth-network/pyth-c…
tejasbadadare Aug 19, 2025
f34b7fc
fix(sui-lazer): finalize state module with trusted signer management …
devin-ai-integration[bot] Aug 19, 2025
112cd39
Merge branch 'tb/lazer/sui-storage' of github.com:pyth-network/pyth-c…
tejasbadadare Aug 19, 2025
d7bd862
chore(sui-lazer): remove warnings in state module; clean build on Sui…
devin-ai-integration[bot] Aug 19, 2025
48e070d
feat(lazer/sui): add trusted signer state
tejasbadadare Aug 19, 2025
8ee64ba
feat(lazer/sui): add package init and AdminCapability; share State an…
devin-ai-integration[bot] Aug 19, 2025
a7436fa
comments
tejasbadadare Aug 19, 2025
88ef465
Merge branch 'tb/lazer/sui-storage' of github.com:pyth-network/pyth-c…
tejasbadadare Aug 19, 2025
e64eb21
resolve warnings
tejasbadadare Aug 19, 2025
187f0e9
feat(sui-lazer): add admin-gated entry to update trusted signer via A…
devin-ai-integration[bot] Aug 19, 2025
ddad6d6
Revert "feat(sui-lazer): add admin-gated entry to update trusted sign…
devin-ai-integration[bot] Aug 19, 2025
0fc417e
naming, make update_trusted_signer public
tejasbadadare Aug 20, 2025
da1a2a1
feat(sui-lazer): use OTW for AdminCap in admin::init; add OTW to pyth…
devin-ai-integration[bot] Aug 20, 2025
389f5d7
lint
tejasbadadare Aug 20, 2025
f1b6fc4
doc
tejasbadadare Aug 20, 2025
96eaa10
feat(lazer/sui): verify against trusted signers
tejasbadadare Aug 21, 2025
f53e83f
docs, clean up
tejasbadadare Aug 21, 2025
b9a844d
remove unneeded otw type check
tejasbadadare Aug 22, 2025
9c92f6e
Merge branch 'tb/lazer/sui-storage' of github.com:pyth-network/pyth-c…
tejasbadadare Aug 22, 2025
41c66dc
lint
tejasbadadare Aug 22, 2025
7f10b9d
Merge branch 'tb/lazer/sui-storage' of github.com:pyth-network/pyth-c…
tejasbadadare Aug 22, 2025
d8028d1
max verify_le_ecdsa_message package private
tejasbadadare Aug 25, 2025
54ad564
Merge branch 'main' of github.com:pyth-network/pyth-crosschain into t…
tejasbadadare Aug 25, 2025
049363c
fix imports
tejasbadadare Aug 25, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions lazer/contracts/sui/sources/admin.move
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
module pyth_lazer::admin;
use sui::types;

public struct AdminCap has key, store {
id: UID,
Expand All @@ -10,7 +9,6 @@ public struct AdminCap has key, store {
/// See: https://move-book.com/programmability/one-time-witness
public struct ADMIN has drop {}


/// Initializes the module. Called at publish time.
/// Creates and transfers ownership of the singular AdminCap capability to the deployer.
/// Only the AdminCap owner can update the trusted signers.
Expand Down
96 changes: 76 additions & 20 deletions lazer/contracts/sui/sources/pyth_lazer.move
Original file line number Diff line number Diff line change
@@ -1,22 +1,26 @@
module pyth_lazer::pyth_lazer;

use pyth_lazer::i16::Self;
use pyth_lazer::i64::Self;
use pyth_lazer::update::{Self, Update};
use pyth_lazer::channel;
use pyth_lazer::feed::{Self, Feed};
use pyth_lazer::channel::Self;
use pyth_lazer::state;
use pyth_lazer::state::{Self, State};
use pyth_lazer::i64::{Self};
use pyth_lazer::i16::{Self};
use pyth_lazer::update::{Self, Update};
use sui::bcs;
use sui::clock::Clock;
use sui::ecdsa_k1::secp256k1_ecrecover;

const SECP256K1_SIG_LEN: u32 = 65;
const UPDATE_MESSAGE_MAGIC: u32 = 1296547300;
const PAYLOAD_MAGIC: u32 = 2479346549;

// Error codes
const EInvalidUpdate: u64 = 1;
const ESignerNotTrusted: u64 = 2;
const ESignerExpired: u64 = 3;

// TODO:
// error handling
// standalone verify signature function

/// The `PYTH_LAZER` resource serves as the one-time witness.
/// It has the `drop` ability, allowing it to be consumed immediately after use.
Expand All @@ -31,13 +35,62 @@ fun init(_: PYTH_LAZER, ctx: &mut TxContext) {
transfer::public_share_object(s);
}

/// Parse the Lazer update message and validate the signature.
/// Verify LE ECDSA message signature against trusted signers.
///
/// This function recovers the public key from the signature and payload,
/// then checks if the recovered public key is in the trusted signers list
/// and has not expired.
///
/// # Arguments
/// * `s` - The pyth_lazer::state::State
/// * `clock` - The sui::clock::Clock
/// * `signature` - The ECDSA signature bytes (little endian)
/// * `payload` - The message payload that was signed
///
/// # Errors
/// * `ESignerNotTrusted` - The recovered public key is not in the trusted signers list
/// * `ESignerExpired` - The signer's certificate has expired
public(package) fun verify_le_ecdsa_message(
s: &State,
clock: &Clock,
signature: &vector<u8>,
payload: &vector<u8>,
) {
// 0 stands for keccak256 hash
let pubkey = secp256k1_ecrecover(signature, payload, 0);

// Check if the recovered pubkey is in the trusted signers list
let trusted_signers = state::get_trusted_signers(s);
let mut maybe_idx = state::find_signer_index(trusted_signers, &pubkey);

if (option::is_some(&maybe_idx)) {
let idx = option::extract(&mut maybe_idx);
let found_signer = &trusted_signers[idx];
let expires_at = state::expires_at(found_signer);
assert!(clock.timestamp_ms() < expires_at, ESignerExpired);
} else {
abort ESignerNotTrusted
}
}

/// Parse the Lazer update message and validate the signature within.
/// 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 {
///
/// # Arguments
/// * `s` - The pyth_lazer::state::State
/// * `clock` - The sui::clock::Clock
/// * `update` - The LeEcdsa formatted Lazer update
///
/// # Errors
/// * `EInvalidUpdate` - Failed to parse the update according to the protocol definition
/// * `ESignerNotTrusted` - The recovered public key is not in the trusted signers list
public fun parse_and_verify_le_ecdsa_update(s: &State, clock: &Clock, update: vector<u8>): Update {
let mut cursor = bcs::new(update);

// TODO: introduce helper functions to check data len before peeling. allows us to return more
// granular error messages.

let magic = cursor.peel_u32();
assert!(magic == UPDATE_MESSAGE_MAGIC, 0);

Expand All @@ -55,18 +108,15 @@ public fun parse_and_verify_le_ecdsa_update(update: vector<u8>): Update {

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();

// Verify the signature against trusted signers
verify_le_ecdsa_message(s, clock, &signature, &payload);

let channel_value = cursor.peel_u8();
let channel = if (channel_value == 0) {
channel::new_invalid()
Expand Down Expand Up @@ -97,7 +147,7 @@ public fun parse_and_verify_le_ecdsa_update(update: vector<u8>): Update {
option::none(),
option::none(),
option::none(),
option::none()
option::none(),
);

let properties_count = cursor.peel_u8();
Expand All @@ -116,14 +166,18 @@ public fun parse_and_verify_le_ecdsa_update(update: vector<u8>): Update {
} 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))));
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))));
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()));
}
Expand Down Expand Up @@ -162,14 +216,16 @@ public fun parse_and_verify_le_ecdsa_update(update: vector<u8>): Update {

if (exists == 1) {
let funding_rate_interval = cursor.peel_u64();
feed.set_funding_rate_interval(option::some(option::some(funding_rate_interval)));
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
abort EInvalidUpdate // FIXME: return more granular error messages
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this no longer a fixme?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still a FIXME since there are several other places in the parsing where the code can revert. Will be tackling this next.

};

properties_i = properties_i + 1;
Expand Down
40 changes: 24 additions & 16 deletions lazer/contracts/sui/sources/state.move
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
module pyth_lazer::state;

use pyth_lazer::admin::AdminCap;
use pyth_lazer::admin;
use pyth_lazer::admin::{Self, AdminCap};

const ED25519_PUBKEY_LEN: u64 = 32;
const SECP256K1_COMPRESSED_PUBKEY_LEN: u64 = 33;
Copy link
Contributor

@merolish merolish Aug 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was this mislabeled?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gave a more accurate name

const EInvalidPubkeyLen: u64 = 1;
const ESignerNotFound: u64 = 2;


/// Lazer State consists of the current set of trusted signers.
/// By verifying that a price update was signed by one of these public keys,
/// you can validate the authenticity of a Lazer price update.
Expand All @@ -18,7 +16,7 @@ public struct State has key, store {
trusted_signers: vector<TrustedSignerInfo>,
}

/// A trusted signer is comprised of a pubkey and an expiry time.
/// A trusted signer is comprised of a pubkey and an expiry timestamp (seconds since Unix epoch).
/// A signer's signature should only be trusted up to timestamp `expires_at`.
public struct TrustedSignerInfo has copy, drop, store {
public_key: vector<u8>,
Expand All @@ -37,7 +35,7 @@ public fun public_key(info: &TrustedSignerInfo): &vector<u8> {
&info.public_key
}

/// Get the trusted signer's expiry timestamp
/// Get the trusted signer's expiry timestamp (seconds since Unix epoch)
public fun expires_at(info: &TrustedSignerInfo): u64 {
info.expires_at
}
Expand All @@ -52,7 +50,7 @@ public fun get_trusted_signers(s: &State): &vector<TrustedSignerInfo> {
/// - If the expired_at is set to zero, the trusted signer will be removed.
/// - If the pubkey isn't found, it is added as a new trusted signer with the given expires_at.
public fun update_trusted_signer(_: &AdminCap, s: &mut State, pubkey: vector<u8>, expires_at: u64) {
assert!(vector::length(&pubkey) as u64 == ED25519_PUBKEY_LEN, EInvalidPubkeyLen);
assert!(vector::length(&pubkey) as u64 == SECP256K1_COMPRESSED_PUBKEY_LEN, EInvalidPubkeyLen);

let mut maybe_idx = find_signer_index(&s.trusted_signers, &pubkey);
if (expires_at == 0) {
Expand Down Expand Up @@ -80,12 +78,15 @@ public fun update_trusted_signer(_: &AdminCap, s: &mut State, pubkey: vector<u8>
}
}

fun find_signer_index(signers: &vector<TrustedSignerInfo>, target: &vector<u8>): Option<u64> {
public fun find_signer_index(
signers: &vector<TrustedSignerInfo>,
public_key: &vector<u8>,
): Option<u64> {
let len = vector::length(signers);
let mut i: u64 = 0;
while (i < (len as u64)) {
let info_ref = vector::borrow(signers, i);
if (*public_key(info_ref) == *target) {
if (*public_key(info_ref) == *public_key) {
return option::some(i)
};
i = i + 1
Expand All @@ -101,13 +102,20 @@ public fun new_for_test(ctx: &mut TxContext): State {
}
}

#[test_only]
public fun destroy_for_test(s: State) {
let State { id, trusted_signers } = s;
let _ = trusted_signers;
object::delete(id);
}

#[test]
public fun test_add_new_signer() {
let mut ctx = tx_context::dummy();
let mut s = new_for_test(&mut ctx);
let admin_cap = admin::mint_for_test(&mut ctx);

let pk = x"0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20";
let pk = x"030102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20";
let expiry: u64 = 123;

update_trusted_signer(&admin_cap, &mut s, pk, expiry);
Expand All @@ -117,7 +125,7 @@ public fun test_add_new_signer() {
let info = vector::borrow(signers_ref, 0);
assert!(expires_at(info) == 123, 101);
let got_pk = public_key(info);
assert!(vector::length(got_pk) == (ED25519_PUBKEY_LEN as u64), 102);
assert!(vector::length(got_pk) == (SECP256K1_COMPRESSED_PUBKEY_LEN as u64), 102);
let State { id, trusted_signers } = s;
let _ = trusted_signers;
object::delete(id);
Expand All @@ -133,13 +141,13 @@ public fun test_update_existing_signer_expiry() {
update_trusted_signer(
&admin_cap,
&mut s,
x"2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a",
x"032a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a",
1000,
);
update_trusted_signer(
&admin_cap,
&mut s,
x"2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a",
x"032a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a",
2000,
);

Expand All @@ -162,13 +170,13 @@ public fun test_remove_signer_by_zero_expiry() {
update_trusted_signer(
&admin_cap,
&mut s,
x"0707070707070707070707070707070707070707070707070707070707070707",
x"030707070707070707070707070707070707070707070707070707070707070707",
999,
);
update_trusted_signer(
&admin_cap,
&mut s,
x"0707070707070707070707070707070707070707070707070707070707070707",
x"030707070707070707070707070707070707070707070707070707070707070707",
0,
);

Expand Down Expand Up @@ -204,7 +212,7 @@ public fun test_remove_nonexistent_signer_fails() {
update_trusted_signer(
&admin_cap,
&mut s,
x"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
x"03aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
0,
);
let State { id, trusted_signers } = s;
Expand Down
Loading