Skip to content

Commit 5aaa580

Browse files
authoredJun 4, 2025··
Add guardian signature (#1)
·
v1.1.1v1.0.0
1 parent 2981a89 commit 5aaa580

File tree

13 files changed

+7717
-1
lines changed

13 files changed

+7717
-1
lines changed
 
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
name: Pre-commit checks
2+
3+
on:
4+
pull_request:
5+
push:
6+
branches: [main]
7+
8+
jobs:
9+
pre-commit:
10+
runs-on: ubuntu-latest
11+
12+
steps:
13+
- uses: actions/checkout@v3
14+
with:
15+
# Need to grab the history of the PR
16+
fetch-depth: 0
17+
18+
- name: Set up Python (for pre-commit)
19+
uses: actions/setup-python@v5
20+
with:
21+
python-version: '3.x'
22+
23+
- name: Install pre-commit
24+
run: pip install pre-commit
25+
26+
- uses: actions-rs/toolchain@v1
27+
with:
28+
profile: minimal
29+
toolchain: 1.87.0
30+
components: clippy
31+
- uses: actions-rs/toolchain@v1
32+
with:
33+
profile: minimal
34+
toolchain: nightly-2024-12-03
35+
components: rustfmt
36+
37+
- name: Run pre-commit on all files
38+
run: pre-commit run --all-files

‎.gitignore‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
/target
2+
.secret

‎.pre-commit-config.yaml‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
repos:
2+
- repo: local
3+
hooks:
4+
# Hooks for workspace
5+
- id: cargo-fmt-workspace
6+
name: Cargo format for workspace
7+
language: "rust"
8+
entry: cargo +nightly-2024-12-03 fmt --manifest-path ./Cargo.toml --all -- --config-path rustfmt.toml
9+
pass_filenames: false
10+
types_or: ["rust", "cargo", "cargo-lock"]
11+
files: .
12+
- id: cargo-clippy-workspace
13+
name: Cargo clippy for workspace
14+
language: "rust"
15+
entry: cargo +1.87.0 clippy --manifest-path ./Cargo.toml --tests -- -D warnings
16+
pass_filenames: false
17+
types_or: ["rust", "cargo", "cargo-lock"]
18+
files: .
19+
- id: cargo-test-workspace
20+
name: Cargo test for workspace
21+
language: "rust"
22+
entry: cargo +1.87.0 test --manifest-path ./Cargo.toml
23+
pass_filenames: false
24+
types_or: ["rust", "cargo", "cargo-lock"]
25+
files: .

‎Cargo.lock‎

Lines changed: 7215 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
[package]
2+
name = "pythnet-watcher"
3+
version = "0.1.0"
4+
edition = "2021"
5+
6+
[dependencies]
7+
anyhow = "1.0.98"
8+
borsh = "0.9.3"
9+
clap = { version = "4.5.39", features = ["derive", "env"] }
10+
secp256k1 = { version = "0.31.0", features = ["recovery"] }
11+
serde = "1.0.219"
12+
serde_wormhole = "0.1.0"
13+
sha3 = "0.10.8"
14+
solana-account-decoder = "2.2.7"
15+
solana-client = "2.2.7"
16+
solana-sdk = "2.2.2"
17+
tokio = "1.45.1"
18+
tokio-stream = "0.1.17"
19+
tracing = "0.1.41"
20+
wormhole-vaas-serde = "0.1.0"

‎LICENSE‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
Copyright 2025 Pyth Contributors.
2+
3+
Licensed under the Apache License, Version 2.0 (the "License");
4+
you may not use this file except in compliance with the License.
5+
You may obtain a copy of the License at
6+
7+
http://www.apache.org/licenses/LICENSE-2.0
8+
9+
Unless required by applicable law or agreed to in writing, software
10+
distributed under the License is distributed on an "AS IS" BASIS,
11+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
See the License for the specific language governing permissions and
13+
limitations under the License.

‎README.md‎

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,52 @@
1-
# pythnet-watcher
1+
# Pythnet Watcher
2+
3+
This project is a Rust-based utility for listening to messages on **Pythnet**, processing, and signing them.
4+
5+
---
6+
7+
## 🚀 Getting Started
8+
9+
### 🛠️ Build the Project
10+
11+
```bash
12+
cargo build --release
13+
```
14+
15+
Or for development:
16+
17+
```bash
18+
cargo build
19+
```
20+
21+
---
22+
23+
### ▶️ Run the Project
24+
25+
You can run the project using `cargo run` by passing the required flags:
26+
27+
```bash
28+
cargo run -- \
29+
--pythnet-url wss://api2.pythnet.pyth.network \
30+
--secret-key /path/to/secret.key \
31+
--wormhole-pid H3fxXJ86ADW2PNuDDmZJg6mzTtPxkYCpNuQUTgmJ7AjU \
32+
```
33+
34+
---
35+
36+
### 🌱 Environment Variables (Optional)
37+
38+
Instead of CLI flags, you can also set environment variables:
39+
40+
```bash
41+
export PYTHNET_URL=wss://api2.pythnet.pyth.network
42+
export SECRET_KEY=/path/to/secret.key
43+
export WORMHOLE_PID=H3fxXJ86ADW2PNuDDmZJg6mzTtPxkYCpNuQUTgmJ7AjU
44+
45+
cargo run
46+
```
47+
48+
---
49+
50+
### 🧪 Testing Locally
51+
52+
To test in a non-production environment (e.g. with devnet or a local Pythnet fork), just provide a different `--pythnet-url` and optionally use custom `--wormhole-pid`.

‎rust-toolchain.toml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[toolchain]
2+
channel = "1.87.0"

‎rustfmt.toml‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
edition = "2021"
2+
3+
# Merge all imports into a clean vertical list of module imports.
4+
imports_granularity = "One"
5+
group_imports = "One"
6+
imports_layout = "Vertical"
7+
8+
# Better grep-ability.
9+
empty_item_single_line = false
10+
11+
# Consistent pipe layout.
12+
match_arm_leading_pipes = "Preserve"
13+
14+
# Align Fields
15+
enum_discrim_align_threshold = 80
16+
struct_field_align_threshold = 80
17+
18+
# Allow up to two blank lines for visual grouping.
19+
blank_lines_upper_bound = 2

‎src/config.rs‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
use clap::Parser;
2+
3+
#[derive(Parser, Clone, Debug)]
4+
pub struct RunOptions {
5+
/// The API key to use for auction server authentication.
6+
#[arg(long = "pythnet-url", env = "PYTHNET_URL")]
7+
pub pythnet_url: String,
8+
/// Path to the file containing the secret key.
9+
#[arg(long = "secret-key", env = "SECRET_KEY")]
10+
pub secret_key_path: String,
11+
/// The Wormhole program ID.
12+
#[arg(
13+
long = "wormhole-pid",
14+
env = "WORMHOLE_PID",
15+
default_value = "H3fxXJ86ADW2PNuDDmZJg6mzTtPxkYCpNuQUTgmJ7AjU"
16+
)]
17+
pub wormhole_pid: String,
18+
}

‎src/main.rs‎

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
use {
2+
borsh::BorshDeserialize,
3+
clap::Parser,
4+
posted_message::PostedMessageUnreliableData,
5+
secp256k1::SecretKey,
6+
signed_body::SignedBody,
7+
solana_account_decoder::UiAccountEncoding,
8+
solana_client::{
9+
nonblocking::pubsub_client::PubsubClient,
10+
pubsub_client::PubsubClientError,
11+
rpc_config::{
12+
RpcAccountInfoConfig,
13+
RpcProgramAccountsConfig,
14+
},
15+
rpc_filter::{
16+
Memcmp,
17+
RpcFilterType,
18+
},
19+
},
20+
solana_sdk::pubkey::Pubkey,
21+
std::{
22+
fs,
23+
str::FromStr,
24+
time::Duration,
25+
},
26+
tokio::time::sleep,
27+
tokio_stream::StreamExt,
28+
wormhole_sdk::{
29+
vaa::Body,
30+
Address,
31+
Chain,
32+
},
33+
};
34+
35+
mod config;
36+
mod posted_message;
37+
mod signed_body;
38+
39+
struct ListenerConfig {
40+
ws_url: String,
41+
secret_key: SecretKey,
42+
wormhole_pid: Pubkey,
43+
accumulator_address: Pubkey,
44+
}
45+
46+
fn find_message_pda(wormhole_pid: &Pubkey, slot: u64) -> Pubkey {
47+
let ring_index = (slot % 10_000) as u32;
48+
Pubkey::find_program_address(
49+
&[b"AccumulatorMessage", &ring_index.to_be_bytes()],
50+
wormhole_pid,
51+
)
52+
.0
53+
}
54+
55+
async fn run_listener(config: ListenerConfig) -> Result<(), PubsubClientError> {
56+
let client = PubsubClient::new(config.ws_url.as_str()).await?;
57+
let (mut stream, unsubscribe) = client
58+
.program_subscribe(
59+
&config.wormhole_pid,
60+
Some(RpcProgramAccountsConfig {
61+
filters: Some(vec![RpcFilterType::Memcmp(Memcmp::new(
62+
0,
63+
solana_client::rpc_filter::MemcmpEncodedBytes::Bytes(b"msu".to_vec()),
64+
))]),
65+
account_config: RpcAccountInfoConfig {
66+
encoding: Some(UiAccountEncoding::Base64),
67+
data_slice: None,
68+
commitment: Some(
69+
solana_sdk::commitment_config::CommitmentConfig::confirmed(),
70+
),
71+
min_context_slot: None,
72+
},
73+
with_context: None,
74+
sort_results: None,
75+
}),
76+
)
77+
.await?;
78+
79+
while let Some(update) = stream.next().await {
80+
if find_message_pda(&config.wormhole_pid, update.context.slot).to_string()
81+
!= update.value.pubkey
82+
{
83+
continue; // Skip updates that are not for the expected PDA
84+
}
85+
86+
let unreliable_data: PostedMessageUnreliableData = {
87+
let data = match update.value.account.data.decode() {
88+
Some(data) => data,
89+
None => {
90+
tracing::error!("Failed to decode account data");
91+
continue;
92+
}
93+
};
94+
95+
match BorshDeserialize::deserialize(&mut data.as_slice()) {
96+
Ok(data) => data,
97+
Err(e) => {
98+
tracing::error!(error = ?e, "Invalid unreliable data format");
99+
continue;
100+
}
101+
}
102+
};
103+
104+
if Chain::Pythnet != unreliable_data.emitter_chain.into() {
105+
continue;
106+
}
107+
if config.accumulator_address != Pubkey::from(unreliable_data.emitter_address) {
108+
continue;
109+
}
110+
111+
let body = Body {
112+
timestamp: unreliable_data.submission_time,
113+
nonce: unreliable_data.nonce,
114+
emitter_chain: unreliable_data.emitter_chain.into(),
115+
emitter_address: Address(unreliable_data.emitter_address),
116+
sequence: unreliable_data.sequence,
117+
consistency_level: unreliable_data.consistency_level,
118+
payload: unreliable_data.payload.clone(),
119+
};
120+
121+
match SignedBody::try_new(body, config.secret_key) {
122+
Ok(signed_body) => println!("Signed Body: {:?}", signed_body),
123+
Err(e) => tracing::error!(error = ?e, "Failed to sign body"),
124+
};
125+
}
126+
127+
tokio::spawn(async move { unsubscribe().await });
128+
129+
Err(PubsubClientError::ConnectionClosed(
130+
"Stream ended".to_string(),
131+
))
132+
}
133+
134+
fn load_secret_key(path: String) -> SecretKey {
135+
let bytes = fs::read(path.clone()).expect("Invalid secret key file");
136+
if bytes.len() == 32 {
137+
let byte_array: [u8; 32] = bytes.try_into().expect("Invalid secret key length");
138+
return SecretKey::from_byte_array(byte_array).expect("Invalid secret key length");
139+
}
140+
141+
let content = fs::read_to_string(path)
142+
.expect("Invalid secret key file")
143+
.trim()
144+
.to_string();
145+
SecretKey::from_str(&content).expect("Invalid secret key")
146+
}
147+
148+
#[tokio::main]
149+
async fn main() {
150+
let run_options = config::RunOptions::parse();
151+
let secret_key = load_secret_key(run_options.secret_key_path);
152+
let client = PubsubClient::new(&run_options.pythnet_url)
153+
.await
154+
.expect("Invalid WebSocket URL");
155+
drop(client); // Drop the client to avoid holding the connection open
156+
let accumulator_address = Pubkey::from_str("G9LV2mp9ua1znRAfYwZz5cPiJMAbo1T6mbjdQsDZuMJg")
157+
.expect("Invalid accumulator address");
158+
let wormhole_pid =
159+
Pubkey::from_str(&run_options.wormhole_pid).expect("Invalid Wormhole program ID");
160+
161+
loop {
162+
if let Err(e) = run_listener(ListenerConfig {
163+
ws_url: run_options.pythnet_url.clone(),
164+
secret_key,
165+
wormhole_pid,
166+
accumulator_address,
167+
})
168+
.await
169+
{
170+
tracing::error!(error = ?e, "Error listening to messages");
171+
sleep(Duration::from_millis(200)).await; // Wait before retrying
172+
}
173+
}
174+
}

‎src/posted_message.rs‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
use {
2+
borsh::{
3+
BorshDeserialize,
4+
BorshSerialize,
5+
},
6+
serde::{
7+
Deserialize,
8+
Serialize,
9+
},
10+
std::{
11+
io::{
12+
Error,
13+
ErrorKind::InvalidData,
14+
Write,
15+
},
16+
ops::{
17+
Deref,
18+
DerefMut,
19+
},
20+
},
21+
};
22+
23+
#[derive(Default, Debug, Clone)]
24+
pub struct PostedMessageUnreliableData {
25+
pub message: MessageData,
26+
}
27+
28+
#[derive(Debug, Default, BorshSerialize, BorshDeserialize, Clone, Serialize, Deserialize)]
29+
pub struct MessageData {
30+
/// Header of the posted VAA
31+
pub vaa_version: u8,
32+
33+
/// Level of consistency requested by the emitter
34+
pub consistency_level: u8,
35+
36+
/// Time the vaa was submitted
37+
pub vaa_time: u32,
38+
39+
/// Account where signatures are stored
40+
pub vaa_signature_account: [u8; 32],
41+
42+
/// Time the posted message was created
43+
pub submission_time: u32,
44+
45+
/// Unique nonce for this message
46+
pub nonce: u32,
47+
48+
/// Sequence number of this message
49+
pub sequence: u64,
50+
51+
/// Emitter of the message
52+
pub emitter_chain: u16,
53+
54+
/// Emitter of the message
55+
pub emitter_address: [u8; 32],
56+
57+
/// Message payload
58+
pub payload: Vec<u8>,
59+
}
60+
61+
impl BorshSerialize for PostedMessageUnreliableData {
62+
fn serialize<W: Write>(&self, writer: &mut W) -> std::io::Result<()> {
63+
writer.write_all(b"msu")?;
64+
BorshSerialize::serialize(&self.message, writer)
65+
}
66+
}
67+
68+
impl BorshDeserialize for PostedMessageUnreliableData {
69+
fn deserialize(buf: &mut &[u8]) -> std::io::Result<Self> {
70+
if buf.len() < 3 {
71+
return Err(Error::new(InvalidData, "Not enough bytes"));
72+
}
73+
74+
let expected = b"msu";
75+
let magic: &[u8] = &buf[0..3];
76+
if magic != expected {
77+
return Err(Error::new(
78+
InvalidData,
79+
format!(
80+
"Magic mismatch. Expected {:?} but got {:?}",
81+
expected, magic
82+
),
83+
));
84+
};
85+
*buf = &buf[3..];
86+
Ok(PostedMessageUnreliableData {
87+
message: <MessageData as BorshDeserialize>::deserialize(buf)?,
88+
})
89+
}
90+
}
91+
92+
impl Deref for PostedMessageUnreliableData {
93+
type Target = MessageData;
94+
95+
fn deref(&self) -> &Self::Target {
96+
&self.message
97+
}
98+
}
99+
100+
impl DerefMut for PostedMessageUnreliableData {
101+
fn deref_mut(&mut self) -> &mut Self::Target {
102+
&mut self.message
103+
}
104+
}

‎src/signed_body.rs‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
use {
2+
secp256k1::{
3+
Message,
4+
Secp256k1,
5+
SecretKey,
6+
},
7+
serde::Serialize,
8+
wormhole_sdk::vaa::Body,
9+
};
10+
11+
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
12+
pub struct SignedBody<P> {
13+
pub version: u8,
14+
pub signature: [u8; 65],
15+
pub body: Body<P>,
16+
}
17+
18+
impl<P: Serialize> SignedBody<P> {
19+
pub fn try_new(body: Body<P>, secret_key: SecretKey) -> Result<Self, anyhow::Error> {
20+
let digest = body.digest()?;
21+
let signature = Secp256k1::new()
22+
.sign_ecdsa_recoverable(Message::from_digest(digest.secp256k_hash), &secret_key);
23+
let (recovery_id, signature_bytes) = signature.serialize_compact();
24+
let recovery_id: i32 = recovery_id.into();
25+
let mut signature = [0u8; 65];
26+
signature[..64].copy_from_slice(&signature_bytes);
27+
signature[64] = recovery_id as u8;
28+
29+
Ok(Self {
30+
version: 1,
31+
signature,
32+
body,
33+
})
34+
}
35+
}

0 commit comments

Comments
 (0)
Please sign in to comment.