Parcourir la source

feat: init Lazer Aptos contract (#2381)

* feat: init aptos contract

* fix: remove unnecessary check

* temp key

* add tests

* lints

* fix: typo

* fix: remove num_trusted_signers, remove max signers functionality, add getters for private struct fields, improve tests

* fix: update readme, remove test_initialize

* ci: add aptos move fmt & lint hooks to precommit

* ci: run lazer aptos tests in CI, add precommit deps

* ci: fix ci

* test: add test_verify_invalid_message_fails

* ci: fix precommit

* test: remove unnecessary assert

* fix: use dev-addresses

* feat: add AdminCapability

* feat: add verify_message_with_funder

* docs: add docstrings

* fix: drop PendingAdminCapability after claiming
Tejas Badadare il y a 8 mois
Parent
commit
c9964d9c90

+ 28 - 0
.github/workflows/ci-lazer-aptos-contract.yml

@@ -0,0 +1,28 @@
+on:
+  pull_request:
+    paths:
+      - lazer/contracts/aptos/**
+  push:
+    branches:
+      - main
+
+name: Lazer Aptos Contract
+
+jobs:
+  aptos-tests:
+    name: Aptos tests
+    runs-on: ubuntu-latest
+    defaults:
+      run:
+        working-directory: lazer/contracts/aptos/
+    steps:
+      - uses: actions/checkout@v3
+
+      - name: Download CLI
+        run: wget https://github.com/aptos-labs/aptos-core/releases/download/aptos-cli-v6.1.1/aptos-cli-6.1.1-Ubuntu-22.04-x86_64.zip
+
+      - name: Unzip CLI
+        run: unzip aptos-cli-6.1.1-Ubuntu-22.04-x86_64.zip
+
+      - name: Run tests
+        run: ./aptos move test

+ 9 - 1
.github/workflows/ci-pre-commit.yml

@@ -8,7 +8,6 @@ on:
 env:
   PYTHON_VERSION: "3.11"
   POETRY_VERSION: "1.4.2"
-
 jobs:
   pre-commit:
     runs-on: ubuntu-latest
@@ -59,4 +58,13 @@ jobs:
         with:
           path: ~/.cache/pypoetry
           key: poetry-cache-${{ runner.os }}-${{ steps.setup_python.outputs.python-version }}-${{ env.POETRY_VERSION }}
+      # Install Aptos CLI for Lazer contract formatting and linting
+      - name: Download Aptos CLI
+        run: wget https://github.com/aptos-labs/aptos-core/releases/download/aptos-cli-v6.1.1/aptos-cli-6.1.1-Ubuntu-22.04-x86_64.zip
+      - name: Install Aptos CLI
+        run: |
+          unzip aptos-cli-6.1.1-Ubuntu-22.04-x86_64.zip
+          sudo mv aptos /usr/local/bin/
+          chmod +x /usr/local/bin/aptos
+          aptos update movefmt
       - uses: pre-commit/action@v3.0.0

+ 12 - 0
.pre-commit-config.yaml

@@ -151,3 +151,15 @@ repos:
         entry: cargo +1.82.0 clippy --manifest-path ./target_chains/ethereum/sdk/stylus/Cargo.toml  --all-targets -- --deny warnings
         pass_filenames: false
         files: target_chains/ethereum/sdk/stylus
+      - id: fmt-aptos-lazer
+        name: Format Aptos Lazer contracts
+        language: system
+        entry: aptos move fmt --package-path lazer/contracts/aptos
+        pass_filenames: false
+        files: lazer/contracts/aptos
+      - id: lint-aptos-lazer
+        name: Lint Aptos Lazer contracts
+        language: system
+        entry: aptos move lint --package-dir lazer/contracts/aptos --check-test-code --dev
+        pass_filenames: false
+        files: lazer/contracts/aptos

+ 13 - 0
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-framework.git", subdir = "aptos-framework", rev = "mainnet" }
+
+[addresses]
+pyth_lazer = "_"
+
+[dev-addresses]
+pyth_lazer = "0x8731685005cfb169b4da4bbfab0c91c5ba59508bbd6d26990ee2be7225cb34d1"

+ 45 - 0
lazer/contracts/aptos/README.md

@@ -0,0 +1,45 @@
+## Pyth Lazer Aptos Contract
+
+This package is built using the Move language and Aptos framework.
+
+`PythLazer` is an Aptos contract that allows consumers to easily verify Pyth Lazer updates for use on-chain.
+
+### Build, test, deploy
+
+Install Aptos CLI and set it up:
+
+```shell
+brew install aptos
+aptos --version
+aptos init --network devnet
+```
+
+Compile the contract and run tests:
+
+```shell
+aptos move compile
+aptos move test
+```
+
+Deploy to the network configured in your aptos profile:
+
+```shell
+aptos move publish
+```
+
+Invoke deployed contract functions on-chain:
+
+```shell
+aptos move run --function-id 'default::pyth_lazer::update_trusted_signer' --args 'hex:0x8731685005cfb169b4da4bbfab0c91c5ba59508bbd6d26990ee2be7225cb34d1' 'u64:9999999999'
+```
+
+### 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

+ 210 - 0
lazer/contracts/aptos/sources/pyth_lazer.move

@@ -0,0 +1,210 @@
+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_SUCH_PUBKEY: u64 = 4;
+    const EINVALID_SIGNATURE: u64 = 5;
+    const EINSUFFICIENT_FEE: u64 = 6;
+
+    /// Constants
+    const ED25519_PUBLIC_KEY_LENGTH: u64 = 32;
+
+    /// Admin capability - holder of this resource can perform admin actions, such as key rotations
+    struct AdminCapability has key, store {}
+
+    /// Stores the admin capability until it's claimed
+    struct PendingAdminCapability has key, drop {
+        admin: address
+    }
+
+    /// Stores information about a trusted signer including their public key and expiration
+    struct TrustedSignerInfo has store, drop, copy {
+        pubkey: vector<u8>, // Ed25519 public key (32 bytes)
+        expires_at: u64 // Unix timestamp
+    }
+
+    /// Main storage for the Lazer contract
+    struct Storage has key {
+        treasury: address,
+        single_update_fee: u64,
+        trusted_signers: vector<TrustedSignerInfo>
+    }
+
+    /// Events
+    struct TrustedSignerUpdateEvent has drop, store {
+        pubkey: vector<u8>,
+        expires_at: u64
+    }
+
+    /// Initialize the Lazer contract with top authority and treasury. One-time operation.
+    public entry fun initialize(
+        account: &signer, admin: address, treasury: address
+    ) {
+        // Initialize must be called by the contract account
+        assert!(signer::address_of(account) == @pyth_lazer, ENO_PERMISSIONS);
+        let storage = Storage {
+            treasury,
+            single_update_fee: 1, // Nominal fee
+            trusted_signers: vector::empty()
+        };
+
+        // Store the pending admin capability
+        move_to(account, PendingAdminCapability { admin });
+
+        // Can only be called once. If storage already exists in @pyth_lazer,
+        // this operation will fail (one-time initialization).
+        move_to(account, storage);
+    }
+
+    /// Allows the designated admin to claim their capability
+    public entry fun claim_admin_capability(account: &signer) acquires PendingAdminCapability {
+        let pending = borrow_global<PendingAdminCapability>(@pyth_lazer);
+        assert!(signer::address_of(account) == pending.admin, ENO_PERMISSIONS);
+
+        // Create and move the admin capability to the claiming account
+        move_to(account, AdminCapability {});
+
+        // Clean up the pending admin capability
+        let PendingAdminCapability { admin: _ } =
+            move_from<PendingAdminCapability>(@pyth_lazer);
+    }
+
+    /// Verify a message signature and collect fee.
+    ///
+    /// This is a convenience wrapper around verify_message(), which allows you to verify an update
+    /// using an entry function. If possible, it is recommended to use update_price_feeds() instead,
+    /// which avoids the need to pass a signer account. update_price_feeds_with_funder() should only
+    /// be used when you need to call an entry function.
+    public entry fun verify_message_with_funder(
+        account: &signer,
+        message: vector<u8>,
+        signature: vector<u8>,
+        trusted_signer: vector<u8>
+    ) acquires Storage {
+        let storage = borrow_global<Storage>(@pyth_lazer);
+
+        // Verify fee payment
+        assert!(
+            coin::balance<AptosCoin>(signer::address_of(account))
+                >= storage.single_update_fee,
+            EINSUFFICIENT_FEE
+        );
+        let fee = coin::withdraw<AptosCoin>(account, storage.single_update_fee);
+        verify_message(message, signature, trusted_signer, fee);
+    }
+
+    /// Verify a message signature with provided fee
+    /// The provided `fee` must contain enough coins to pay a single update fee, which
+    /// can be queried by calling calling get_update_fee().
+    public fun verify_message(
+        message: vector<u8>,
+        signature: vector<u8>,
+        trusted_signer: vector<u8>,
+        fee: coin::Coin<AptosCoin>
+    ) acquires Storage {
+        let storage = borrow_global<Storage>(@pyth_lazer);
+
+        // Verify fee amount
+        assert!(coin::value(&fee) >= storage.single_update_fee, EINSUFFICIENT_FEE);
+
+        // Transfer fee to treasury
+        coin::deposit(storage.treasury, fee);
+
+        // Verify signer is trusted and not expired
+        let i = 0;
+        let valid = false;
+        while (i < storage.trusted_signers.length()) {
+            let signer_info = vector::borrow(&storage.trusted_signers, (i as u64));
+            if (&signer_info.pubkey == &trusted_signer
+                && 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(trusted_signer);
+        assert!(
+            ed25519::signature_verify_strict(&sig, &pk, message),
+            EINVALID_SIGNATURE
+        );
+    }
+
+    /// Upsert a trusted signer's information or remove them
+    public entry fun update_trusted_signer(
+        account: &signer, trusted_signer: vector<u8>, expires_at: u64
+    ) acquires Storage {
+        // Verify admin capability
+        assert!(
+            exists<AdminCapability>(signer::address_of(account)),
+            ENO_PERMISSIONS
+        );
+
+        assert!(
+            vector::length(&trusted_signer) == ED25519_PUBLIC_KEY_LENGTH,
+            EINVALID_SIGNER
+        );
+
+        let storage = borrow_global_mut<Storage>(@pyth_lazer);
+        let num_signers = storage.trusted_signers.length();
+        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));
+        } 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
+            vector::push_back(
+                &mut storage.trusted_signers,
+                TrustedSignerInfo { pubkey: trusted_signer, expires_at }
+            );
+        };
+    }
+
+    /// Returns the list of trusted signers
+    public fun get_trusted_signers(): vector<TrustedSignerInfo> acquires Storage {
+        let storage = borrow_global<Storage>(@pyth_lazer);
+        storage.trusted_signers
+    }
+
+    /// Returns the fee required to verify a message
+    public fun get_update_fee(): u64 acquires Storage {
+        let storage = borrow_global<Storage>(@pyth_lazer);
+        storage.single_update_fee
+    }
+
+    /// Signer pubkey getter
+    public fun get_signer_pubkey(info: &TrustedSignerInfo): vector<u8> {
+        info.pubkey
+    }
+
+    /// Signer expiry getter
+    public fun get_signer_expires_at(info: &TrustedSignerInfo): u64 {
+        info.expires_at
+    }
+}

+ 215 - 0
lazer/contracts/aptos/tests/pyth_lazer_tests.move

@@ -0,0 +1,215 @@
+#[test_only]
+module pyth_lazer::pyth_lazer_tests {
+    use std::signer;
+    use std::vector;
+    use aptos_framework::account;
+    use aptos_framework::coin;
+    use aptos_framework::timestamp;
+    use aptos_framework::aptos_coin::AptosCoin;
+    use pyth_lazer::pyth_lazer::{
+        Self,
+        EINVALID_SIGNER,
+        EINSUFFICIENT_FEE,
+        EINVALID_SIGNATURE,
+        ENO_PERMISSIONS
+    };
+
+    // Test accounts
+    const ADMIN: address =
+        @0x3374049c3b46a907ff2fc6b62af51975fb9dc572b7e73eb1b255ed5edcd7cee0;
+    const TREASURY: address = @0x456;
+    const USER: address = @0x789;
+
+    // Test data, signed with the admin private key
+    const TEST_PUBKEY: vector<u8> = x"3374049c3b46a907ff2fc6b62af51975fb9dc572b7e73eb1b255ed5edcd7cee0";
+    const TEST_MESSAGE: vector<u8> = b"test message";
+    const TEST_SIGNATURE: vector<u8> = x"20ebb15d70abc18abf636d77fa86a89e32596f90569b09e732b556bbc2f8afea07feff8d1beb18f7acd7ef1d3f914163fe03a3b4206f61f932e2d22a21278a01";
+
+    #[test_only]
+    fun setup_aptos_coin(framework: &signer): coin::MintCapability<AptosCoin> {
+        let (burn_cap, freeze_cap, mint_cap) =
+            coin::initialize<AptosCoin>(
+                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 lazer_contract = account::create_account_for_test(@pyth_lazer);
+        let admin = account::create_account_for_test(ADMIN);
+        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<AptosCoin>(&admin);
+        coin::register<AptosCoin>(&treasury);
+        coin::register<AptosCoin>(&user);
+
+        // Give user some coins for fees
+        let coins = coin::mint<AptosCoin>(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 and claim admin capability
+        pyth_lazer::initialize(&lazer_contract, signer::address_of(&admin), TREASURY);
+        pyth_lazer::claim_admin_capability(&admin);
+
+        (admin, treasury, user)
+    }
+
+    #[test]
+    fun test_verify_valid_message_succeeds() {
+        let (admin, _treasury, user) = setup();
+
+        // Add a valid signer
+        let expires_at = timestamp::now_seconds() + 1000;
+        pyth_lazer::update_trusted_signer(&admin, TEST_PUBKEY, expires_at);
+
+        // Get initial balance
+        let initial_balance = coin::balance<AptosCoin>(signer::address_of(&user));
+
+        // This should succeed as we have a valid signer and sufficient fee
+        pyth_lazer::verify_message_with_funder(
+            &user,
+            TEST_MESSAGE,
+            TEST_SIGNATURE,
+            TEST_PUBKEY
+        );
+
+        // Verify fee was withdrawn
+        let final_balance = coin::balance<AptosCoin>(signer::address_of(&user));
+        assert!(final_balance == initial_balance - 1, 0);
+    }
+
+    #[test]
+    #[expected_failure(abort_code = EINVALID_SIGNATURE)]
+    fun test_verify_invalid_message_fails() {
+        let (admin, _treasury, user) = setup();
+
+        // Add a valid signer
+        let expires_at = timestamp::now_seconds() + 1000;
+        pyth_lazer::update_trusted_signer(&admin, TEST_PUBKEY, expires_at);
+
+        // Use a different message than what was signed
+        let invalid_message = b"different message";
+
+        // This should fail with EINVALID_SIGNATURE since the signature
+        // was created for TEST_MESSAGE, not invalid_message
+        pyth_lazer::verify_message_with_funder(
+            &user,
+            invalid_message,
+            TEST_SIGNATURE,
+            TEST_PUBKEY
+        );
+    }
+
+    #[test]
+    fun test_add_update_remove_signers_succeeds() {
+        let (admin, _treasury, _) = setup();
+
+        // Add multiple signers
+        let expires_at = timestamp::now_seconds() + 1000;
+        let pubkey1 = x"1111111111111111111111111111111111111111111111111111111111111111";
+        let pubkey2 = x"2222222222222222222222222222222222222222222222222222222222222222";
+        let pubkey3 = x"3333333333333333333333333333333333333333333333333333333333333333";
+
+        pyth_lazer::update_trusted_signer(&admin, pubkey1, expires_at);
+        pyth_lazer::update_trusted_signer(&admin, pubkey2, expires_at);
+        pyth_lazer::update_trusted_signer(&admin, pubkey3, expires_at);
+
+        // Verify signers were added
+        let trusted_signers = pyth_lazer::get_trusted_signers();
+        assert!(vector::length(&trusted_signers) == 3, 0);
+
+        // Verify first signer
+        let signer_info = vector::borrow(&trusted_signers, 0);
+        let signer_pubkey = pyth_lazer::get_signer_pubkey(signer_info);
+        let signer_expires_at = pyth_lazer::get_signer_expires_at(signer_info);
+        assert!(signer_pubkey == pubkey1, 0);
+        assert!(signer_expires_at == expires_at, 0);
+
+        // Update second signer
+        let new_expires_at = timestamp::now_seconds() + 2000;
+        pyth_lazer::update_trusted_signer(&admin, pubkey2, new_expires_at);
+
+        // Verify second signer was updated
+        let trusted_signers = pyth_lazer::get_trusted_signers();
+        let signer_info = vector::borrow(&trusted_signers, 1);
+        let signer_pubkey = pyth_lazer::get_signer_pubkey(signer_info);
+        let signer_expires_at = pyth_lazer::get_signer_expires_at(signer_info);
+        assert!(signer_pubkey == pubkey2, 0);
+        assert!(signer_expires_at == new_expires_at, 0);
+
+        // Remove all signers
+        pyth_lazer::update_trusted_signer(&admin, pubkey1, 0);
+        pyth_lazer::update_trusted_signer(&admin, pubkey2, 0);
+        pyth_lazer::update_trusted_signer(&admin, pubkey3, 0);
+
+        // Verify all signers were removed
+        let trusted_signers = pyth_lazer::get_trusted_signers();
+        assert!(vector::length(&trusted_signers) == 0, 0);
+    }
+
+    #[test]
+    #[expected_failure(abort_code = ENO_PERMISSIONS)]
+    fun test_update_signer_without_admin_capability_fails() {
+        let (_, _, user) = setup();
+
+        // Try to add a signer without admin capability
+        let expires_at = timestamp::now_seconds() + 1000;
+        pyth_lazer::update_trusted_signer(&user, TEST_PUBKEY, expires_at);
+    }
+
+    #[test]
+    #[expected_failure(abort_code = EINVALID_SIGNER)]
+    fun test_expired_signer_throws_error() {
+        let (admin, _treasury, user) = setup();
+
+        // Add signer that expires in 1000 seconds
+        let expires_at = timestamp::now_seconds() + 1000;
+        pyth_lazer::update_trusted_signer(&admin, 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_with_funder(
+            &user,
+            TEST_MESSAGE,
+            TEST_SIGNATURE,
+            TEST_PUBKEY
+        );
+    }
+
+    #[test]
+    #[expected_failure(abort_code = EINSUFFICIENT_FEE)]
+    fun test_insufficient_fee_throws_error() {
+        let (_admin, _treasury, user) = setup();
+
+        // Drain user's balance by transferring to treasury
+        let balance = coin::balance<AptosCoin>(signer::address_of(&user));
+        coin::transfer<AptosCoin>(&user, signer::address_of(&_treasury), balance);
+
+        // This should fail due to insufficient fee
+        pyth_lazer::verify_message_with_funder(
+            &user,
+            TEST_MESSAGE,
+            TEST_SIGNATURE,
+            TEST_PUBKEY
+        );
+    }
+}