Skip to content

Commit 005026c

Browse files
feat(lazer/sui): verify signatures against trusted signers (#2982)
* feat(lazer/sui): specify version * feat(lazer-sui): add state module with trusted signer storage and tests Co-Authored-By: Tejas Badadare <[email protected]> * fix Co-Authored-By: Tejas Badadare <[email protected]> * fix(sui-lazer): finalize state module with trusted signer management and tests; verified with Sui CLI 1.53.2 Co-Authored-By: Tejas Badadare <[email protected]> * chore(sui-lazer): remove warnings in state module; clean build on Sui CLI 1.53.2 Co-Authored-By: Tejas Badadare <[email protected]> * feat(lazer/sui): add trusted signer state * feat(lazer/sui): add package init and AdminCapability; share State and gate updates with admin cap Co-Authored-By: Tejas Badadare <[email protected]> * comments * resolve warnings * feat(sui-lazer): add admin-gated entry to update trusted signer via AdminCapability Co-Authored-By: Tejas Badadare <[email protected]> * Revert "feat(sui-lazer): add admin-gated entry to update trusted signer via AdminCapability" This reverts commit 187f0e9. * naming, make update_trusted_signer public * feat(sui-lazer): use OTW for AdminCap in admin::init; add OTW to pyth_lazer::init; share State; add OTW doc comments Co-Authored-By: Tejas Badadare <[email protected]> * lint * doc * feat(lazer/sui): verify against trusted signers * docs, clean up * remove unneeded otw type check * lint * max verify_le_ecdsa_message package private * fix imports --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 3aed1cc commit 005026c

File tree

4 files changed

+282
-89
lines changed

4 files changed

+282
-89
lines changed

lazer/contracts/sui/sources/admin.move

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
module pyth_lazer::admin;
2-
use sui::types;
32

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

13-
1412
/// Initializes the module. Called at publish time.
1513
/// Creates and transfers ownership of the singular AdminCap capability to the deployer.
1614
/// Only the AdminCap owner can update the trusted signers.

lazer/contracts/sui/sources/pyth_lazer.move

Lines changed: 76 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,26 @@
11
module pyth_lazer::pyth_lazer;
22

3-
use pyth_lazer::i16::Self;
4-
use pyth_lazer::i64::Self;
5-
use pyth_lazer::update::{Self, Update};
3+
use pyth_lazer::channel;
64
use pyth_lazer::feed::{Self, Feed};
7-
use pyth_lazer::channel::Self;
8-
use pyth_lazer::state;
5+
use pyth_lazer::state::{Self, State};
6+
use pyth_lazer::i64::{Self};
7+
use pyth_lazer::i16::{Self};
8+
use pyth_lazer::update::{Self, Update};
99
use sui::bcs;
10+
use sui::clock::Clock;
1011
use sui::ecdsa_k1::secp256k1_ecrecover;
1112

1213
const SECP256K1_SIG_LEN: u32 = 65;
1314
const UPDATE_MESSAGE_MAGIC: u32 = 1296547300;
1415
const PAYLOAD_MAGIC: u32 = 2479346549;
1516

17+
// Error codes
18+
const EInvalidUpdate: u64 = 1;
19+
const ESignerNotTrusted: u64 = 2;
20+
const ESignerExpired: u64 = 3;
1621

1722
// TODO:
1823
// error handling
19-
// standalone verify signature function
2024

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

34-
/// Parse the Lazer update message and validate the signature.
38+
/// Verify LE ECDSA message signature against trusted signers.
39+
///
40+
/// This function recovers the public key from the signature and payload,
41+
/// then checks if the recovered public key is in the trusted signers list
42+
/// and has not expired.
3543
///
44+
/// # Arguments
45+
/// * `s` - The pyth_lazer::state::State
46+
/// * `clock` - The sui::clock::Clock
47+
/// * `signature` - The ECDSA signature bytes (little endian)
48+
/// * `payload` - The message payload that was signed
49+
///
50+
/// # Errors
51+
/// * `ESignerNotTrusted` - The recovered public key is not in the trusted signers list
52+
/// * `ESignerExpired` - The signer's certificate has expired
53+
public(package) fun verify_le_ecdsa_message(
54+
s: &State,
55+
clock: &Clock,
56+
signature: &vector<u8>,
57+
payload: &vector<u8>,
58+
) {
59+
// 0 stands for keccak256 hash
60+
let pubkey = secp256k1_ecrecover(signature, payload, 0);
61+
62+
// Check if the recovered pubkey is in the trusted signers list
63+
let trusted_signers = state::get_trusted_signers(s);
64+
let mut maybe_idx = state::find_signer_index(trusted_signers, &pubkey);
65+
66+
if (option::is_some(&maybe_idx)) {
67+
let idx = option::extract(&mut maybe_idx);
68+
let found_signer = &trusted_signers[idx];
69+
let expires_at = state::expires_at(found_signer);
70+
assert!(clock.timestamp_ms() < expires_at, ESignerExpired);
71+
} else {
72+
abort ESignerNotTrusted
73+
}
74+
}
75+
76+
/// Parse the Lazer update message and validate the signature within.
3677
/// The parsing logic is based on the Lazer rust protocol definition defined here:
3778
/// https://github.com/pyth-network/pyth-crosschain/tree/main/lazer/sdk/rust/protocol
38-
public fun parse_and_verify_le_ecdsa_update(update: vector<u8>): Update {
79+
///
80+
/// # Arguments
81+
/// * `s` - The pyth_lazer::state::State
82+
/// * `clock` - The sui::clock::Clock
83+
/// * `update` - The LeEcdsa formatted Lazer update
84+
///
85+
/// # Errors
86+
/// * `EInvalidUpdate` - Failed to parse the update according to the protocol definition
87+
/// * `ESignerNotTrusted` - The recovered public key is not in the trusted signers list
88+
public fun parse_and_verify_le_ecdsa_update(s: &State, clock: &Clock, update: vector<u8>): Update {
3989
let mut cursor = bcs::new(update);
4090

91+
// TODO: introduce helper functions to check data len before peeling. allows us to return more
92+
// granular error messages.
93+
4194
let magic = cursor.peel_u32();
4295
assert!(magic == UPDATE_MESSAGE_MAGIC, 0);
4396

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

56109
assert!((payload_len as u64) == payload.length(), 0);
57110

58-
// 0 stands for keccak256 hash
59-
let pubkey = secp256k1_ecrecover(&signature, &payload, 0);
60-
61-
// Lazer signer pubkey
62-
// FIXME: validate against trusted signer set in storage
63-
assert!(pubkey == x"03a4380f01136eb2640f90c17e1e319e02bbafbeef2e6e67dc48af53f9827e155b", 0);
64-
65111
let mut cursor = bcs::new(payload);
66112
let payload_magic = cursor.peel_u32();
67113
assert!(payload_magic == PAYLOAD_MAGIC, 0);
68114

69115
let timestamp = cursor.peel_u64();
116+
117+
// Verify the signature against trusted signers
118+
verify_le_ecdsa_message(s, clock, &signature, &payload);
119+
70120
let channel_value = cursor.peel_u8();
71121
let channel = if (channel_value == 0) {
72122
channel::new_invalid()
@@ -97,7 +147,7 @@ public fun parse_and_verify_le_ecdsa_update(update: vector<u8>): Update {
97147
option::none(),
98148
option::none(),
99149
option::none(),
100-
option::none()
150+
option::none(),
101151
);
102152

103153
let properties_count = cursor.peel_u8();
@@ -116,14 +166,18 @@ public fun parse_and_verify_le_ecdsa_update(update: vector<u8>): Update {
116166
} else if (property_id == 1) {
117167
let best_bid_price = cursor.peel_u64();
118168
if (best_bid_price != 0) {
119-
feed.set_best_bid_price(option::some(option::some(i64::from_u64(best_bid_price))));
169+
feed.set_best_bid_price(
170+
option::some(option::some(i64::from_u64(best_bid_price))),
171+
);
120172
} else {
121173
feed.set_best_bid_price(option::some(option::none()));
122174
}
123175
} else if (property_id == 2) {
124176
let best_ask_price = cursor.peel_u64();
125177
if (best_ask_price != 0) {
126-
feed.set_best_ask_price(option::some(option::some(i64::from_u64(best_ask_price))));
178+
feed.set_best_ask_price(
179+
option::some(option::some(i64::from_u64(best_ask_price))),
180+
);
127181
} else {
128182
feed.set_best_ask_price(option::some(option::none()));
129183
}
@@ -162,14 +216,16 @@ public fun parse_and_verify_le_ecdsa_update(update: vector<u8>): Update {
162216

163217
if (exists == 1) {
164218
let funding_rate_interval = cursor.peel_u64();
165-
feed.set_funding_rate_interval(option::some(option::some(funding_rate_interval)));
219+
feed.set_funding_rate_interval(
220+
option::some(option::some(funding_rate_interval)),
221+
);
166222
} else {
167223
feed.set_funding_rate_interval(option::some(option::none()));
168224
}
169225
} else {
170226
// When we have an unknown property, we do not know its length, and therefore
171227
// we cannot ignore it and parse the next properties.
172-
abort 0 // FIXME: return more granular error messages
228+
abort EInvalidUpdate // FIXME: return more granular error messages
173229
};
174230

175231
properties_i = properties_i + 1;

lazer/contracts/sui/sources/state.move

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
11
module pyth_lazer::state;
22

3-
use pyth_lazer::admin::AdminCap;
4-
use pyth_lazer::admin;
3+
use pyth_lazer::admin::{Self, AdminCap};
54

6-
const ED25519_PUBKEY_LEN: u64 = 32;
5+
const SECP256K1_COMPRESSED_PUBKEY_LEN: u64 = 33;
76
const EInvalidPubkeyLen: u64 = 1;
87
const ESignerNotFound: u64 = 2;
98

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

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

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

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

83-
fun find_signer_index(signers: &vector<TrustedSignerInfo>, target: &vector<u8>): Option<u64> {
81+
public fun find_signer_index(
82+
signers: &vector<TrustedSignerInfo>,
83+
public_key: &vector<u8>,
84+
): Option<u64> {
8485
let len = vector::length(signers);
8586
let mut i: u64 = 0;
8687
while (i < (len as u64)) {
8788
let info_ref = vector::borrow(signers, i);
88-
if (*public_key(info_ref) == *target) {
89+
if (*public_key(info_ref) == *public_key) {
8990
return option::some(i)
9091
};
9192
i = i + 1
@@ -101,13 +102,20 @@ public fun new_for_test(ctx: &mut TxContext): State {
101102
}
102103
}
103104

105+
#[test_only]
106+
public fun destroy_for_test(s: State) {
107+
let State { id, trusted_signers } = s;
108+
let _ = trusted_signers;
109+
object::delete(id);
110+
}
111+
104112
#[test]
105113
public fun test_add_new_signer() {
106114
let mut ctx = tx_context::dummy();
107115
let mut s = new_for_test(&mut ctx);
108116
let admin_cap = admin::mint_for_test(&mut ctx);
109117

110-
let pk = x"0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20";
118+
let pk = x"030102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20";
111119
let expiry: u64 = 123;
112120

113121
update_trusted_signer(&admin_cap, &mut s, pk, expiry);
@@ -117,7 +125,7 @@ public fun test_add_new_signer() {
117125
let info = vector::borrow(signers_ref, 0);
118126
assert!(expires_at(info) == 123, 101);
119127
let got_pk = public_key(info);
120-
assert!(vector::length(got_pk) == (ED25519_PUBKEY_LEN as u64), 102);
128+
assert!(vector::length(got_pk) == (SECP256K1_COMPRESSED_PUBKEY_LEN as u64), 102);
121129
let State { id, trusted_signers } = s;
122130
let _ = trusted_signers;
123131
object::delete(id);
@@ -133,13 +141,13 @@ public fun test_update_existing_signer_expiry() {
133141
update_trusted_signer(
134142
&admin_cap,
135143
&mut s,
136-
x"2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a",
144+
x"032a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a",
137145
1000,
138146
);
139147
update_trusted_signer(
140148
&admin_cap,
141149
&mut s,
142-
x"2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a",
150+
x"032a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a",
143151
2000,
144152
);
145153

@@ -162,13 +170,13 @@ public fun test_remove_signer_by_zero_expiry() {
162170
update_trusted_signer(
163171
&admin_cap,
164172
&mut s,
165-
x"0707070707070707070707070707070707070707070707070707070707070707",
173+
x"030707070707070707070707070707070707070707070707070707070707070707",
166174
999,
167175
);
168176
update_trusted_signer(
169177
&admin_cap,
170178
&mut s,
171-
x"0707070707070707070707070707070707070707070707070707070707070707",
179+
x"030707070707070707070707070707070707070707070707070707070707070707",
172180
0,
173181
);
174182

@@ -204,7 +212,7 @@ public fun test_remove_nonexistent_signer_fails() {
204212
update_trusted_signer(
205213
&admin_cap,
206214
&mut s,
207-
x"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
215+
x"03aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
208216
0,
209217
);
210218
let State { id, trusted_signers } = s;

0 commit comments

Comments
 (0)