diff --git a/lazer/contracts/sui/Move.lock b/lazer/contracts/sui/Move.lock index d08a0eb8d4..125f200e2f 100644 --- a/lazer/contracts/sui/Move.lock +++ b/lazer/contracts/sui/Move.lock @@ -2,7 +2,7 @@ [move] version = 3 -manifest_digest = "DD0B86B0E012F788977D2224EA46B39395FCF48AB7DAE200E70E6E12F9445868" +manifest_digest = "5B8FA4A1860DFE72BCB751FB8D867DA8D63CCFD7029F175B345B072433DFC568" deps_digest = "F9B494B64F0615AED0E98FC12A85B85ECD2BC5185C22D30E7F67786BB52E507C" dependencies = [ { id = "Bridge", name = "Bridge" }, diff --git a/lazer/contracts/sui/Move.toml b/lazer/contracts/sui/Move.toml index ae1617355c..19ca0072da 100644 --- a/lazer/contracts/sui/Move.toml +++ b/lazer/contracts/sui/Move.toml @@ -1,9 +1,8 @@ [package] name = "pyth_lazer" +version = "0.0.0" edition = "2024.beta" -[dependencies] - [addresses] pyth_lazer = "0x0" diff --git a/lazer/contracts/sui/README.md b/lazer/contracts/sui/README.md index 28c83546d0..7074925d47 100644 --- a/lazer/contracts/sui/README.md +++ b/lazer/contracts/sui/README.md @@ -2,7 +2,7 @@ `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. +This package is built using the Move language edition `2024.beta` and Sui framework `v1.53.2`. ### Build, test, deploy diff --git a/lazer/contracts/sui/sources/admin.move b/lazer/contracts/sui/sources/admin.move new file mode 100644 index 0000000000..ab20d4492e --- /dev/null +++ b/lazer/contracts/sui/sources/admin.move @@ -0,0 +1,31 @@ +module pyth_lazer::admin; +use sui::types; + +public struct AdminCap has key, store { + id: UID, +} + +/// The `ADMIN` resource serves as the one-time witness. +/// It has the `drop` ability, allowing it to be consumed immediately after use. +/// 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. +fun init(_: ADMIN, ctx: &mut TxContext) { + let cap = AdminCap { id: object::new(ctx) }; + transfer::public_transfer(cap, tx_context::sender(ctx)); +} + +#[test_only] +public fun mint_for_test(ctx: &mut TxContext): AdminCap { + AdminCap { id: object::new(ctx) } +} + +#[test_only] +public fun destroy_for_test(cap: AdminCap) { + let AdminCap { id } = cap; + object::delete(id) +} diff --git a/lazer/contracts/sui/sources/pyth_lazer.move b/lazer/contracts/sui/sources/pyth_lazer.move index 0dabd24756..102518d7a7 100644 --- a/lazer/contracts/sui/sources/pyth_lazer.move +++ b/lazer/contracts/sui/sources/pyth_lazer.move @@ -5,6 +5,7 @@ use pyth_lazer::i64::Self; use pyth_lazer::update::{Self, Update}; use pyth_lazer::feed::{Self, Feed}; use pyth_lazer::channel::Self; +use pyth_lazer::state; use sui::bcs; use sui::ecdsa_k1::secp256k1_ecrecover; @@ -14,12 +15,22 @@ 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 +/// The `PYTH_LAZER` resource serves as the one-time witness. +/// It has the `drop` ability, allowing it to be consumed immediately after use. +/// See: https://move-book.com/programmability/one-time-witness +public struct PYTH_LAZER has drop {} + +/// Initializes the module. Called at publish time. +/// Creates and shares the singular State object. +/// AdminCap is created and transferred in admin::init via a One-Time Witness. +fun init(_: PYTH_LAZER, ctx: &mut TxContext) { + let s = state::new(ctx); + transfer::public_share_object(s); +} + /// Parse the Lazer update message and validate the signature. /// /// The parsing logic is based on the Lazer rust protocol definition defined here: diff --git a/lazer/contracts/sui/sources/state.move b/lazer/contracts/sui/sources/state.move new file mode 100644 index 0000000000..a2c351efbb --- /dev/null +++ b/lazer/contracts/sui/sources/state.move @@ -0,0 +1,214 @@ +module pyth_lazer::state; + +use pyth_lazer::admin::AdminCap; +use pyth_lazer::admin; + +const ED25519_PUBKEY_LEN: u64 = 32; +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. +/// +/// The trusted signers are subject to rotations and expiry. +public struct State has key, store { + id: UID, + trusted_signers: vector, +} + +/// A trusted signer is comprised of a pubkey and an expiry time. +/// A signer's signature should only be trusted up to timestamp `expires_at`. +public struct TrustedSignerInfo has copy, drop, store { + public_key: vector, + expires_at: u64, +} + +public(package) fun new(ctx: &mut TxContext): State { + State { + id: object::new(ctx), + trusted_signers: vector::empty(), + } +} + +/// Get the trusted signer's public key +public fun public_key(info: &TrustedSignerInfo): &vector { + &info.public_key +} + +/// Get the trusted signer's expiry timestamp +public fun expires_at(info: &TrustedSignerInfo): u64 { + info.expires_at +} + +/// Get the list of trusted signers +public fun get_trusted_signers(s: &State): &vector { + &s.trusted_signers +} + +/// Upsert a trusted signer's information or remove them. Can only be called by the AdminCap holder. +/// - If the trusted signer pubkey already exists, the expires_at will be updated. +/// - 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, expires_at: u64) { + assert!(vector::length(&pubkey) as u64 == ED25519_PUBKEY_LEN, EInvalidPubkeyLen); + + let mut maybe_idx = find_signer_index(&s.trusted_signers, &pubkey); + if (expires_at == 0) { + if (option::is_some(&maybe_idx)) { + let idx = option::extract(&mut maybe_idx); + // Remove by swapping with last (order not preserved), discard removed value + let _ = vector::swap_remove(&mut s.trusted_signers, idx); + } else { + option::destroy_none(maybe_idx); + abort ESignerNotFound + }; + return + }; + + if (option::is_some(&maybe_idx)) { + let idx = option::extract(&mut maybe_idx); + let info_ref = vector::borrow_mut(&mut s.trusted_signers, idx); + info_ref.expires_at = expires_at + } else { + option::destroy_none(maybe_idx); + vector::push_back( + &mut s.trusted_signers, + TrustedSignerInfo { public_key: pubkey, expires_at }, + ) + } +} + +fun find_signer_index(signers: &vector, target: &vector): Option { + 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) { + return option::some(i) + }; + i = i + 1 + }; + option::none() +} + +#[test_only] +public fun new_for_test(ctx: &mut TxContext): State { + State { + id: object::new(ctx), + trusted_signers: vector::empty(), + } +} + +#[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 expiry: u64 = 123; + + update_trusted_signer(&admin_cap, &mut s, pk, expiry); + + let signers_ref = get_trusted_signers(&s); + assert!(vector::length(signers_ref) == 1, 100); + 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); + let State { id, trusted_signers } = s; + let _ = trusted_signers; + object::delete(id); + admin::destroy_for_test(admin_cap); +} + +#[test] +public fun test_update_existing_signer_expiry() { + let mut ctx = tx_context::dummy(); + let mut s = new_for_test(&mut ctx); + let admin_cap = admin::mint_for_test(&mut ctx); + + update_trusted_signer( + &admin_cap, + &mut s, + x"2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a", + 1000, + ); + update_trusted_signer( + &admin_cap, + &mut s, + x"2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a", + 2000, + ); + + let signers_ref = get_trusted_signers(&s); + assert!(vector::length(signers_ref) == 1, 110); + let info = vector::borrow(signers_ref, 0); + assert!(expires_at(info) == 2000, 111); + let State { id, trusted_signers } = s; + let _ = trusted_signers; + object::delete(id); + admin::destroy_for_test(admin_cap); +} + +#[test] +public fun test_remove_signer_by_zero_expiry() { + let mut ctx = tx_context::dummy(); + let mut s = new_for_test(&mut ctx); + let admin_cap = admin::mint_for_test(&mut ctx); + + update_trusted_signer( + &admin_cap, + &mut s, + x"0707070707070707070707070707070707070707070707070707070707070707", + 999, + ); + update_trusted_signer( + &admin_cap, + &mut s, + x"0707070707070707070707070707070707070707070707070707070707070707", + 0, + ); + + let signers_ref = get_trusted_signers(&s); + assert!(vector::length(signers_ref) == 0, 120); + let State { id, trusted_signers } = s; + let _ = trusted_signers; + object::delete(id); + admin::destroy_for_test(admin_cap); +} + +#[test, expected_failure(abort_code = EInvalidPubkeyLen)] +public fun test_invalid_pubkey_length_rejected() { + let mut ctx = tx_context::dummy(); + let mut s = new_for_test(&mut ctx); + let admin_cap = admin::mint_for_test(&mut ctx); + + let short_pk = x"010203"; + update_trusted_signer(&admin_cap, &mut s, short_pk, 1); + let State { id, trusted_signers } = s; + let _ = trusted_signers; + object::delete(id); + admin::destroy_for_test(admin_cap); +} + +#[test, expected_failure(abort_code = ESignerNotFound)] +public fun test_remove_nonexistent_signer_fails() { + let mut ctx = tx_context::dummy(); + let mut s = new_for_test(&mut ctx); + let admin_cap = admin::mint_for_test(&mut ctx); + + // Try to remove a signer that doesn't exist by setting expires_at to 0 + update_trusted_signer( + &admin_cap, + &mut s, + x"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + 0, + ); + let State { id, trusted_signers } = s; + let _ = trusted_signers; + object::delete(id); + admin::destroy_for_test(admin_cap); +}