Skip to content

Commit dc064c9

Browse files
authored
feat(bulk): signature domain changes (#3951)
1 parent 52de73b commit dc064c9

9 files changed

Lines changed: 108 additions & 23 deletions

File tree

Cargo.lock

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

apps/pyth-lazer-pusher/bulk-trade-cli/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ anyhow = "1.0"
1616
ratatui = "0.29"
1717
crossterm = "0.28"
1818
reqwest = { version = "0.12", features = ["json"] }
19-
bulk-keychain = "0.1.17"
19+
bulk-keychain = "0.1.22"
2020

2121
[lints]
2222
workspace = true

apps/pyth-lazer-pusher/bulk-trade-pusher/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ serde = { version = "1.0", features = ["derive"] }
2424
serde_json = "1.0"
2525

2626
# Crypto (for signing)
27-
bulk-keychain = "0.1.17"
27+
bulk-keychain = "0.1.22"
2828

2929
# Config
3030
clap = { version = "4.5", features = ["derive"] }

apps/pyth-lazer-pusher/bulk-trade-pusher/README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,20 @@ Required settings:
1212
- `lazer.access_token` - Pyth Lazer API token
1313
- `bulk.signing_key_path` - Path to Ed25519 key file
1414
- `bulk.oracle_account_pubkey_base58` - Oracle account (whitelisted on Bulk)
15+
- `bulk.signature_domain` - BULK network to sign for: `mainnet`, `testnet`, or `devnet`
1516
- `feeds.subscriptions` - Feed IDs to push
1617

1718
Environment overrides: `BULK_PUSHER__LAZER__ACCESS_TOKEN`, etc.
1819

20+
### Signature domain
21+
22+
`bulk.signature_domain` must match the network the configured `bulk.endpoints` belong
23+
to. It is committed into the signature preimage but is **not** part of the transaction
24+
sent over the wire, so a mismatch produces a well-formed transaction whose signature the
25+
validator rejects — with no indication that the domain was the cause. The field is
26+
required and has no default so the mistake surfaces at startup instead. The active
27+
domain is logged in the `initialized signer` startup line.
28+
1929
## Run
2030

2131
```bash

apps/pyth-lazer-pusher/bulk-trade-pusher/config.example.toml

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,18 +38,26 @@ endpoints = [
3838
"wss://exchange-wss.bulk.trade/ws",
3939
]
4040

41-
# Ed25519 signing key (base58 encoded private key)
41+
# Path to the file holding the Ed25519 signing key (base58 encoded private key)
4242
# Each pusher instance has its OWN signing key that's approved for the oracle account.
43-
# Can also be set via BULK_PUSHER__BULK__SIGNING_KEY_BASE58 env var
44-
# IMPORTANT: Keep this secret! Use env var in production.
45-
signing_key_base58 = "your-base58-encoded-private-key"
43+
# Can also be set via BULK_PUSHER__BULK__SIGNING_KEY_PATH env var
44+
# IMPORTANT: Keep the key file secret and mount it as a secret in production.
45+
signing_key_path = "/secrets/signing.key"
4646

4747
# Oracle account public key (base58 encoded)
4848
# This is the "account" field in the transaction - SHARED by all pushers.
4949
# The signing key must be approved/whitelisted for this account on Bulk's side.
5050
# Must be a valid base58-encoded Ed25519 public key.
5151
oracle_account_pubkey_base58 = "your-base58-encoded-oracle-account-pubkey"
5252

53+
# BULK network this pusher signs for: "mainnet", "testnet", or "devnet".
54+
# REQUIRED - there is no default.
55+
# Must match the network that the endpoints above belong to. The domain is committed
56+
# into the signature but is NOT sent over the wire, so a wrong value yields a
57+
# well-formed transaction that the validator silently rejects as a bad signature.
58+
# Can also be set via BULK_PUSHER__BULK__SIGNATURE_DOMAIN env var.
59+
signature_domain = "mainnet"
60+
5361
# Feed Configuration
5462
[feeds]
5563
# How often to batch and push prices (should match Bulk's expected interval)

apps/pyth-lazer-pusher/bulk-trade-pusher/src/config.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use anyhow::Context as _;
2+
use bulk_keychain::SignatureDomain;
23
use config::{Environment, File};
34
use pusher_base::BaseConfig;
45
use serde::Deserialize;
@@ -41,6 +42,14 @@ pub struct BulkConfig {
4142
/// This is the "account" field in the transaction - shared by all pushers.
4243
/// The signing key must be approved/whitelisted for this account.
4344
pub oracle_account_pubkey_base58: String,
45+
46+
/// BULK network the transactions are signed for ("mainnet", "testnet", or "devnet").
47+
///
48+
/// Required - there is deliberately no default. The domain is committed into the
49+
/// signature preimage but is *not* part of the transaction sent over the wire, so a
50+
/// wrong value produces a well-formed transaction that the validator silently rejects
51+
/// as an invalid signature. Failing at startup is much easier to diagnose than guessing.
52+
pub signature_domain: SignatureDomain,
4453
}
4554

4655
/// Load configuration from a TOML file and validate it.

apps/pyth-lazer-pusher/bulk-trade-pusher/src/pusher.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,16 @@ pub async fn run(config: Config, runtime: AppRuntime) -> Result<()> {
1919
info!("initializing bulk-trade-pusher");
2020

2121
let signing_key = load_signing_key(&config.bulk.signing_key_path)?;
22-
let signer = BulkSigner::new(&signing_key, &config.bulk.oracle_account_pubkey_base58)
23-
.context("failed to initialize signer")?;
22+
let signer = BulkSigner::new(
23+
&signing_key,
24+
&config.bulk.oracle_account_pubkey_base58,
25+
config.bulk.signature_domain,
26+
)
27+
.context("failed to initialize signer")?;
2428
info!(
2529
signer_pubkey = signer.pubkey_base58(),
2630
oracle_account = %config.bulk.oracle_account_pubkey_base58,
31+
signature_domain = %config.bulk.signature_domain,
2732
"initialized signer"
2833
);
2934

apps/pyth-lazer-pusher/bulk-trade-pusher/src/signing.rs

Lines changed: 64 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
//! the account.
77
88
use anyhow::Result;
9-
use bulk_keychain::{Action, Keypair, Pubkey, PythOraclePrice, SignedTransaction, Signer};
9+
use bulk_keychain::{
10+
Action, Keypair, Pubkey, PythOraclePrice, SignatureDomain, SignedTransaction, Signer,
11+
};
1012

1113
/// Wrapper for signing keys using bulk-keychain.
1214
///
@@ -21,15 +23,23 @@ pub struct BulkSigner {
2123

2224
impl BulkSigner {
2325
/// Create a new signer from a base58-encoded private key and oracle account.
24-
pub fn new(private_key_base58: &str, oracle_account_base58: &str) -> Result<Self> {
26+
///
27+
/// `signature_domain` selects the BULK network the signature commits to; it must
28+
/// match the network the configured validator endpoints belong to, otherwise the
29+
/// signature is rejected (see `BulkConfig::signature_domain`).
30+
pub fn new(
31+
private_key_base58: &str,
32+
oracle_account_base58: &str,
33+
signature_domain: SignatureDomain,
34+
) -> Result<Self> {
2535
let keypair = Keypair::from_base58(private_key_base58)
2636
.map_err(|e| anyhow::anyhow!("failed to parse keypair: {}", e))?;
2737

2838
let oracle_account = Pubkey::from_base58(oracle_account_base58)
2939
.map_err(|e| anyhow::anyhow!("failed to parse oracle account pubkey: {}", e))?;
3040

3141
let pubkey_base58 = keypair.pubkey().to_string();
32-
let signer = Signer::new(keypair);
42+
let signer = Signer::new(keypair, signature_domain);
3343

3444
Ok(Self {
3545
signer,
@@ -66,33 +76,39 @@ mod tests {
6676
// Test keypair (generated offline, safe for tests only)
6777
const TEST_PRIVATE_KEY_BASE58: &str = "4wBqpZM9k1k4reVTJezJTqcPYLkuJSYwZYfwJC3xjYw9";
6878

79+
const TEST_DOMAIN: SignatureDomain = SignatureDomain::Devnet;
80+
6981
fn test_oracle_account() -> String {
7082
// Use the signer's own pubkey as oracle account for tests
7183
let keypair = Keypair::from_base58(TEST_PRIVATE_KEY_BASE58).unwrap();
7284
keypair.pubkey().to_string()
7385
}
7486

87+
fn test_signer() -> BulkSigner {
88+
BulkSigner::new(TEST_PRIVATE_KEY_BASE58, &test_oracle_account(), TEST_DOMAIN).unwrap()
89+
}
90+
7591
#[test]
7692
fn test_signer_creation() {
77-
let signer = BulkSigner::new(TEST_PRIVATE_KEY_BASE58, &test_oracle_account()).unwrap();
93+
let signer = test_signer();
7894
assert!(!signer.pubkey_base58().is_empty());
7995
}
8096

8197
#[test]
8298
fn test_signer_invalid_key() {
83-
let result = BulkSigner::new("invalid-key", "invalid-account");
99+
let result = BulkSigner::new("invalid-key", "invalid-account", TEST_DOMAIN);
84100
assert!(result.is_err());
85101
}
86102

87103
#[test]
88104
fn test_signer_empty_key() {
89-
let result = BulkSigner::new("", "");
105+
let result = BulkSigner::new("", "", TEST_DOMAIN);
90106
assert!(result.is_err());
91107
}
92108

93109
#[test]
94110
fn test_sign_transaction() {
95-
let mut signer = BulkSigner::new(TEST_PRIVATE_KEY_BASE58, &test_oracle_account()).unwrap();
111+
let mut signer = test_signer();
96112

97113
let oracles = vec![PythOraclePrice {
98114
timestamp: 1704067200000,
@@ -112,7 +128,8 @@ mod tests {
112128
#[test]
113129
fn test_sign_transaction_multiple_oracles() {
114130
let oracle_account = test_oracle_account();
115-
let mut signer = BulkSigner::new(TEST_PRIVATE_KEY_BASE58, &oracle_account).unwrap();
131+
let mut signer =
132+
BulkSigner::new(TEST_PRIVATE_KEY_BASE58, &oracle_account, TEST_DOMAIN).unwrap();
116133

117134
let oracles = vec![
118135
PythOraclePrice {
@@ -149,7 +166,8 @@ mod tests {
149166
fn test_sign_transaction_uses_oracle_account() {
150167
// Verify the transaction uses the oracle account, not the signer's own pubkey
151168
let oracle_account = test_oracle_account();
152-
let mut signer = BulkSigner::new(TEST_PRIVATE_KEY_BASE58, &oracle_account).unwrap();
169+
let mut signer =
170+
BulkSigner::new(TEST_PRIVATE_KEY_BASE58, &oracle_account, TEST_DOMAIN).unwrap();
153171

154172
let oracles = vec![PythOraclePrice {
155173
timestamp: 1000,
@@ -164,9 +182,42 @@ mod tests {
164182
assert_eq!(tx.signer, signer.pubkey_base58());
165183
}
166184

185+
#[test]
186+
fn test_signature_domain_changes_signature() {
187+
// The domain is committed into the signature preimage but is not part of the
188+
// transaction payload, so a mismatch is only observable as a rejected signature.
189+
// Signing identical input under different domains must diverge.
190+
let oracle_account = test_oracle_account();
191+
let oracles = vec![PythOraclePrice {
192+
timestamp: 1000,
193+
feed_index: 1,
194+
price: 100,
195+
exponent: -2,
196+
}];
197+
198+
let sign_with = |domain| {
199+
BulkSigner::new(TEST_PRIVATE_KEY_BASE58, &oracle_account, domain)
200+
.unwrap()
201+
.sign_transaction(oracles.clone(), 42)
202+
.unwrap()
203+
};
204+
205+
let mainnet = sign_with(SignatureDomain::Mainnet);
206+
let testnet = sign_with(SignatureDomain::Testnet);
207+
let devnet = sign_with(SignatureDomain::Devnet);
208+
209+
assert_ne!(mainnet.signature, testnet.signature);
210+
assert_ne!(mainnet.signature, devnet.signature);
211+
assert_ne!(testnet.signature, devnet.signature);
212+
213+
// The domain must not leak into the serialized payload.
214+
let json = serde_json::to_string(&mainnet).unwrap();
215+
assert!(!json.contains("mainnet"));
216+
}
217+
167218
#[test]
168219
fn test_deterministic_signature() {
169-
let mut signer = BulkSigner::new(TEST_PRIVATE_KEY_BASE58, &test_oracle_account()).unwrap();
220+
let mut signer = test_signer();
170221

171222
let oracles = vec![PythOraclePrice {
172223
timestamp: 1000,
@@ -184,7 +235,7 @@ mod tests {
184235

185236
#[test]
186237
fn test_different_nonce_different_signature() {
187-
let mut signer = BulkSigner::new(TEST_PRIVATE_KEY_BASE58, &test_oracle_account()).unwrap();
238+
let mut signer = test_signer();
188239

189240
let oracles1 = vec![PythOraclePrice {
190241
timestamp: 1000,
@@ -202,7 +253,7 @@ mod tests {
202253

203254
#[test]
204255
fn test_transaction_json_format() {
205-
let mut signer = BulkSigner::new(TEST_PRIVATE_KEY_BASE58, &test_oracle_account()).unwrap();
256+
let mut signer = test_signer();
206257

207258
let oracles = vec![
208259
PythOraclePrice {
@@ -249,7 +300,7 @@ mod tests {
249300
use bulk_keychain::ed25519_dalek::{Signature, Verifier, VerifyingKey};
250301

251302
let keypair = Keypair::from_base58(TEST_PRIVATE_KEY_BASE58).unwrap();
252-
let mut signer = BulkSigner::new(TEST_PRIVATE_KEY_BASE58, &test_oracle_account()).unwrap();
303+
let mut signer = test_signer();
253304

254305
let oracles = vec![PythOraclePrice {
255306
timestamp: 1704067200000,

apps/pyth-lazer-pusher/tilt/bulk/k8s/tilt/config.toml.tpl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ endpoints = [
1919
]
2020
signing_key_path = "/secrets/signing.key"
2121
oracle_account_pubkey_base58 = "gCGUPL8RFfxndjVYrAQL3z28XtQjY2zJcFu7Tk3CT9Z"
22+
# Local dev targets the mock validator, which does not verify signatures.
23+
signature_domain = "devnet"
2224

2325
[feeds]
2426
update_interval = "500ms"

0 commit comments

Comments
 (0)