diff --git a/lazer/contracts/aptos/Move.toml b/lazer/contracts/aptos/Move.toml new file mode 100644 index 0000000000..417bafbd83 --- /dev/null +++ b/lazer/contracts/aptos/Move.toml @@ -0,0 +1,13 @@ +[package] +name = "pyth_lazer" +version = "0.1.0" +license = "UNLICENSED" + +[dependencies] +AptosFramework = { git = "https://github.com/aptos-labs/aptos-core.git", subdir = "aptos-move/framework/aptos-framework/", rev = "mainnet" } + +[addresses] +pyth_lazer = "0x123" # Using TOP_AUTHORITY address for testing + +# For more details on Move.toml configuration, see: +# https://move-language.github.io/move/packages.html diff --git a/lazer/contracts/aptos/README.md b/lazer/contracts/aptos/README.md new file mode 100644 index 0000000000..f303813e90 --- /dev/null +++ b/lazer/contracts/aptos/README.md @@ -0,0 +1,34 @@ +## Pyth Lazer Aptos Contract + +This package is built using the Move language and Aptos framework. + +`PythLazer` is an Aptos on-chain contract that keeps track of trusted signers of Pyth Lazer payloads. It allows consumers to easily check validity of Pyth Lazer signatures while enabling key rotation. + +### Key Features +- Ed25519 signature verification using Aptos standard library +- Support for up to 2 trusted signers +- Fee collection in Aptos native token +- Signer expiration management + +### Build and Test + +```shell +$ aptos move compile +$ aptos move test +``` + +### Implementation Details +- Uses Ed25519 signature verification from Aptos standard library +- Maintains compatibility with Solana/EVM implementations +- Follows Move best practices for resource management +- Collects 1 wei fee per verification in Aptos native token +- Supports maximum of 2 trusted signers (matching Solana implementation) + +### Error Handling +The contract uses the following error codes: +- ENO_PERMISSIONS (1): Caller lacks required permissions +- EINVALID_SIGNER (2): Invalid or expired signer +- ENO_SPACE (3): Maximum number of signers reached +- ENO_SUCH_PUBKEY (4): Attempting to remove non-existent signer +- EINVALID_SIGNATURE (5): Invalid Ed25519 signature +- EINSUFFICIENT_FEE (6): Insufficient fee provided diff --git a/lazer/contracts/aptos/sources/pyth_lazer.move b/lazer/contracts/aptos/sources/pyth_lazer.move new file mode 100644 index 0000000000..38fd131410 --- /dev/null +++ b/lazer/contracts/aptos/sources/pyth_lazer.move @@ -0,0 +1,140 @@ +module pyth_lazer::pyth_lazer { + use std::vector; + use std::signer; + use aptos_framework::timestamp; + use aptos_framework::coin; + use aptos_framework::aptos_coin::AptosCoin; + use aptos_std::ed25519; + + /// Error codes + const ENO_PERMISSIONS: u64 = 1; + const EINVALID_SIGNER: u64 = 2; + const ENO_SPACE: u64 = 3; + const ENO_SUCH_PUBKEY: u64 = 4; + const EINVALID_SIGNATURE: u64 = 5; + const EINSUFFICIENT_FEE: u64 = 6; + + /// Constants + const MAX_NUM_TRUSTED_SIGNERS: u8 = 2; + const ED25519_PUBLIC_KEY_LENGTH: u64 = 32; + + /// Stores information about a trusted signer including their public key and expiration + struct TrustedSignerInfo has store, drop { + pubkey: vector, // Ed25519 public key (32 bytes) + expires_at: u64, // Unix timestamp + } + + /// Main storage for the Lazer contract + struct Storage has key { + top_authority: address, + treasury: address, + single_update_fee: u64, // Fee in Aptos native token (1 wei) + num_trusted_signers: u8, + trusted_signers: vector, + } + + /// Events + struct TrustedSignerUpdateEvent has drop, store { + pubkey: vector, + expires_at: u64, + } + + /// Initialize the Lazer contract with top authority and treasury + public entry fun initialize( + account: &signer, + top_authority: address, + treasury: address, + ) { + let storage = Storage { + top_authority, + treasury, + single_update_fee: 1, // 1 wei in Aptos native token + num_trusted_signers: 0, + trusted_signers: vector::empty(), + }; + move_to(account, storage); + } + + /// Update a trusted signer's information or remove them + public entry fun update_trusted_signer( + account: &signer, + trusted_signer: vector, + expires_at: u64, + ) acquires Storage { + let storage = borrow_global_mut(@pyth_lazer); + assert!(signer::address_of(account) == storage.top_authority, ENO_PERMISSIONS); + assert!(vector::length(&trusted_signer) == ED25519_PUBLIC_KEY_LENGTH, EINVALID_SIGNER); + + let num_signers = storage.num_trusted_signers; + let i = 0; + let found = false; + + while (i < num_signers) { + let signer_info = vector::borrow(&storage.trusted_signers, (i as u64)); + if (signer_info.pubkey == trusted_signer) { + found = true; + break + }; + i = i + 1; + }; + + if (expires_at == 0) { + // Remove signer + assert!(found, ENO_SUCH_PUBKEY); + vector::remove(&mut storage.trusted_signers, (i as u64)); + storage.num_trusted_signers = storage.num_trusted_signers - 1; + } else if (found) { + // Update existing signer + let signer_info = vector::borrow_mut(&mut storage.trusted_signers, (i as u64)); + signer_info.expires_at = expires_at; + } else { + // Add new signer + assert!(storage.num_trusted_signers < MAX_NUM_TRUSTED_SIGNERS, ENO_SPACE); + vector::push_back(&mut storage.trusted_signers, TrustedSignerInfo { + pubkey: trusted_signer, + expires_at, + }); + storage.num_trusted_signers = storage.num_trusted_signers + 1; + }; + } + + /// Verify a message signature and collect fee + public entry fun verify_message( + account: &signer, + message: vector, + signature: vector, + public_key: vector, + ) acquires Storage { + let storage = borrow_global(@pyth_lazer); + + // Verify fee payment + assert!(coin::balance(signer::address_of(account)) >= storage.single_update_fee, EINSUFFICIENT_FEE); + coin::transfer(account, storage.treasury, storage.single_update_fee); + + // Verify signer is trusted and not expired + let i = 0; + let valid = false; + while (i < storage.num_trusted_signers) { + let signer_info = vector::borrow(&storage.trusted_signers, (i as u64)); + if (signer_info.pubkey == public_key && signer_info.expires_at > timestamp::now_seconds()) { + valid = true; + break + }; + i = i + 1; + }; + assert!(valid, EINVALID_SIGNER); + + // Verify signature + let sig = ed25519::new_signature_from_bytes(signature); + let pk = ed25519::new_unvalidated_public_key_from_bytes(public_key); + assert!(ed25519::signature_verify_strict(&sig, &pk, message), EINVALID_SIGNATURE); + let signer_info = vector::borrow(&storage.trusted_signers, (i as u64)); + if (signer_info.pubkey == public_key && signer_info.expires_at > timestamp::now_seconds()) { + valid = true; + break + }; + i = i + 1; + }; + assert!(valid, EINVALID_SIGNER); + } +} diff --git a/lazer/contracts/aptos/tests/pyth_lazer_tests.move b/lazer/contracts/aptos/tests/pyth_lazer_tests.move new file mode 100644 index 0000000000..d70105f523 --- /dev/null +++ b/lazer/contracts/aptos/tests/pyth_lazer_tests.move @@ -0,0 +1,131 @@ +#[test_only] +module pyth_lazer::pyth_lazer_tests { + use std::signer; + use std::string; + use aptos_framework::account; + use aptos_framework::coin; + use aptos_framework::timestamp; + use aptos_framework::aptos_coin::AptosCoin; + use aptos_std::ed25519; + use pyth_lazer::pyth_lazer; + + // Test accounts + const TOP_AUTHORITY: address = @0x123; + const TREASURY: address = @0x456; + const USER: address = @0x789; + + // Test data + const TEST_PUBKEY: vector = x"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + const TEST_MESSAGE: vector = x"deadbeef"; + const TEST_SIGNATURE: vector = x"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + + #[test_only] + fun setup_aptos_coin(framework: &signer): coin::MintCapability { + let (burn_cap, freeze_cap, mint_cap) = coin::initialize( + framework, + std::string::utf8(b"Aptos Coin"), + std::string::utf8(b"APT"), + 8, + false, + ); + coin::destroy_burn_cap(burn_cap); + coin::destroy_freeze_cap(freeze_cap); + mint_cap + } + + fun setup(): (signer, signer, signer) { + // Create test accounts + let framework = account::create_account_for_test(@aptos_framework); + let top_authority = account::create_account_for_test(TOP_AUTHORITY); + let treasury = account::create_account_for_test(TREASURY); + let user = account::create_account_for_test(USER); + + // Setup AptosCoin and get mint capability + let mint_cap = setup_aptos_coin(&framework); + + // Register accounts for AptosCoin + coin::register(&top_authority); + coin::register(&treasury); + coin::register(&user); + + // Give user some coins for fees + let coins = coin::mint(1000, &mint_cap); + coin::deposit(signer::address_of(&user), coins); + coin::destroy_mint_cap(mint_cap); + + // Initialize timestamp for expiration tests + timestamp::set_time_has_started_for_testing(&framework); + + // Initialize contract + pyth_lazer::initialize(&top_authority, TOP_AUTHORITY, TREASURY); + + (top_authority, treasury, user) + } + + #[test] + fun test_initialize() { + let (top_authority, _treasury, _) = setup(); + // Contract is already initialized in setup + } + + #[test] + fun test_update_add_signer() { + let (top_authority, _treasury, _) = setup(); + + // Add signer + let expires_at = timestamp::now_seconds() + 1000; + pyth_lazer::update_trusted_signer(&top_authority, TEST_PUBKEY, expires_at); + + // Update signer + let new_expires_at = timestamp::now_seconds() + 2000; + pyth_lazer::update_trusted_signer(&top_authority, TEST_PUBKEY, new_expires_at); + + // Remove signer + pyth_lazer::update_trusted_signer(&top_authority, TEST_PUBKEY, 0); + } + + #[test] + #[expected_failure(abort_code = pyth_lazer::ENO_SPACE)] + fun test_max_signers() { + let (top_authority, _treasury, _) = setup(); + + let expires_at = timestamp::now_seconds() + 1000; + let pubkey1 = x"1111111111111111111111111111111111111111111111111111111111111111"; + let pubkey2 = x"2222222222222222222222222222222222222222222222222222222222222222"; + let pubkey3 = x"3333333333333333333333333333333333333333333333333333333333333333"; + + pyth_lazer::update_trusted_signer(&top_authority, pubkey1, expires_at); + pyth_lazer::update_trusted_signer(&top_authority, pubkey2, expires_at); + // This should fail as we already have 2 signers + pyth_lazer::update_trusted_signer(&top_authority, pubkey3, expires_at); + } + + #[test] + #[expected_failure(abort_code = pyth_lazer::EINVALID_SIGNER)] + fun test_expired_signer() { + let (top_authority, _treasury, user) = setup(); + + // Add signer that expires in 1000 seconds + let expires_at = timestamp::now_seconds() + 1000; + pyth_lazer::update_trusted_signer(&top_authority, TEST_PUBKEY, expires_at); + + // Move time forward past expiration + timestamp::fast_forward_seconds(2000); + + // This should fail as the signer is expired + pyth_lazer::verify_message(&user, TEST_MESSAGE, TEST_SIGNATURE, TEST_PUBKEY); + } + + #[test] + #[expected_failure(abort_code = pyth_lazer::EINSUFFICIENT_FEE)] + fun test_insufficient_fee() { + let (top_authority, _treasury, user) = setup(); + + // Drain user's balance + let user_balance = coin::balance(signer::address_of(&user)); + coin::transfer(&user, TREASURY, user_balance); + + // This should fail due to insufficient fee + pyth_lazer::verify_message(&user, TEST_MESSAGE, TEST_SIGNATURE, TEST_PUBKEY); + } +}