Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
28 changes: 28 additions & 0 deletions .github/workflows/ci-lazer-aptos-contract.yml
Original file line number Diff line number Diff line change
@@ -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
10 changes: 9 additions & 1 deletion .github/workflows/ci-pre-commit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ on:
env:
PYTHON_VERSION: "3.11"
POETRY_VERSION: "1.4.2"

jobs:
pre-commit:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -56,4 +55,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/[email protected]
12 changes: 12 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 13 additions & 0 deletions lazer/contracts/aptos/Move.toml
Original file line number Diff line number Diff line change
@@ -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 changes: 45 additions & 0 deletions lazer/contracts/aptos/README.md
Original file line number Diff line number Diff line change
@@ -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 changes: 210 additions & 0 deletions lazer/contracts/aptos/sources/pyth_lazer.move
Original file line number Diff line number Diff line change
@@ -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);
Copy link
Collaborator

Choose a reason for hiding this comment

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

we need to invalidate the admin capability right? otherwise it seems we can mint infinite capabilities.

more for my edification: could the @pyth get admincapability itself in initialize and then later transfer it to someone else :?

Copy link
Contributor Author

@tejasbadadare tejasbadadare Feb 25, 2025

Choose a reason for hiding this comment

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

we need to invalidate the admin capability right? otherwise it seems we can mint infinite capabilities.

multiple claim attempts by the admin will fail, since there can only be a single resource of a given type under an account, and since the capabilities don't have copy there shouldn't be a way to distribute copies of the capability. i thought about explicitly destroying the PendingAdminCapability after it has been claimed, but thought it might be misleading since move already guarantees it can't be double-claimed.

But, on second thought, leaving it in global state is kinda confusing, and may invite people to try to use it. I'll destroy it after it's been claimed

more for my edification: could the @pyth get admincapability itself in initialize and then later transfer it to someone else :?

and get rid of PendingAdminCapability? yeah i think that would work, but i was following the wormhole deployer's pattern of a pending capability. i'll dig into this.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I looked it up and it seems that in Aptos a non-contract cannot pass it's owned objects to the functions and therefore i don't think they can transfer an object. I'm not sure but i saw this pattern a lot that there's an entrypoint for signer everywhere and then objects are collected (like what you do now) which partially defeats the purpose of this language construct 🤷 SUI handles it properly though.


// 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
}
}
Loading
Loading