Conversation
Set up the lazer/contracts/stellar/ Cargo workspace with two crates: - wormhole-executor-stellar: implements Wormhole VAA binary parsing with error types covering invalid version, truncated data, and malformed sigs - pyth-lazer-stellar: skeleton crate for the Pyth Lazer verification contract Includes 14 unit tests for VAA parsing covering edge cases, multi-signature VAAs, and governance-style payloads. Both crates build for wasm32-unknown-unknown. Co-authored-by: Hydra Worker <hydra-worker@example.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
) * feat: implement Pyth Lazer core verification contract for Stellar Implement the pyth-lazer-stellar Soroban contract with LE-ECDSA signed price update verification. The contract recovers signer public keys via secp256k1_recover + keccak256, compresses them to SEC-1 33-byte format, and validates against a trusted signers map with expiry timestamps. Includes: - error.rs: ContractError enum for all error cases - state.rs: Storage keys, trusted signer management, TTL extension helpers - verify.rs: LE-ECDSA envelope parsing, key recovery, point compression - lib.rs: Contract entry points (initialize, verify_update, update_trusted_signer, upgrade) - test.rs: 9 unit tests using Sui test vectors (success, invalid magic, truncated data, unknown signer, expired signer, multiple signers, etc.) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: update Cargo.lock and add test snapshots for pyth-lazer-stellar Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: remove autogenerated test snapshot JSON files and add .gitignore Soroban test snapshots are auto-generated during test runs and should not be checked in. Added .gitignore to exclude test_snapshots/ dirs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Hydra Worker <hydra-worker@example.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
…#3581) Add 3 tests using real mainnet guardian set upgrade VAAs (from TON test utils) to validate the Stellar Wormhole VAA parser against production data. Each test verifies guardian_set_index, signature count, emitter chain/address, sequence, payload structure (Core module + action), and the new guardian set index in the upgrade payload. Co-authored-by: Hydra Worker <hydra-worker@example.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
…tor (#3578) Implement guardian signature verification using secp256k1_recover and keccak256, Ethereum address derivation, quorum checking (2/3+1), guardian set storage with TTL management, contract initialization, and guardian set upgrade via governance VAAs. Co-authored-by: Hydra Worker <hydra-worker@example.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
…llar (#3579) Add Unauthorized error variant to ContractError and comprehensive governance tests covering: add/update/remove trusted signers, unauthorized caller rejection, and upgrade function executor authorization. Co-authored-by: Hydra Worker <hydra-worker@example.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
…3582) Implement governance.rs module with PTGM header parsing (magic, module, action, target_chain_id), action-specific payload parsing for update_trusted_signer and upgrade actions, and cross-contract dispatch. Add execute_governance_action() to lib.rs with VAA verification, emitter validation, replay protection via strictly increasing sequence numbers, and cross-contract calls to target contracts. Co-authored-by: Hydra Worker <hydra-worker@example.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Update hydra CLI to v0.9.0 in Dockerfile.metis (#3575) Rename metis CLI binary to hydra and pin to v0.9.0 release: - Download URL: latest/metis-x86_64-unknown-linux-gnu -> v0.9.0/hydra-x86_64-unknown-linux-gnu - Install path: /usr/local/bin/metis -> /usr/local/bin/hydra - Comment: metis CLI -> hydra CLI Co-authored-by: Hydra Worker <hydra-worker@example.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * feat: implement payload parsing module for Pyth Lazer Stellar contract Adds payload.rs module that decodes verified Pyth Lazer payload bytes into structured Rust types (Update, Feed, Channel, MarketSession). Parses all 13 property types matching the Sui reference implementation, with correct LE two's complement handling for signed integers and exists-flag handling for optional properties. Includes 9 unit tests using shared test vectors (BTC/USD, ETH/USD, SOL/USD feeds). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Hydra Worker <hydra-worker@example.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Copy the design document from the Hydra document store into the repository so it lives alongside the contract code. Co-authored-by: Hydra Worker <hydra-worker@example.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
…racts (#3584) Fix the pyth-lazer-stellar WASM build by gating the payload module (which uses alloc::vec::Vec) to only compile on non-wasm32 targets and in tests. Add a deployment script that automates building, optimizing, deploying, and initializing both contracts on the Stellar network. Add wasm-opt optimize target to Makefile. Testnet deployment verified: - Executor: CCO3TSB5KEAQRAWFTHB2U7KBE4GNX6PXHZCKOF6JMBBE4GXT3G2QROCU - Lazer: CCU4CZWTQKWG6462X6MDAJR245K2XB4LSUKEACRAHYMSLYENQ6MXKKHE - Both initialized with testnet guardian set - verify_update correctly returns SignerNotTrusted (no trusted signer added yet via governance VAA) Co-authored-by: Hydra Worker <hydra-worker@example.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
…llar contracts (#3583) Add cross-contract integration tests exercising the full governance and verification flows between wormhole-executor-stellar and pyth-lazer-stellar. Create README with architecture overview, build/test/deploy instructions, and enhance Makefile with fmt/clippy/check targets. Integration tests cover: - Full governance flow (VAA -> executor -> Lazer contract) - Full verification flow (signed update -> verify -> parse payload) - Upgrade governance dispatch - Guardian set upgrades followed by governance - Negative cases (expired signer, wrong emitter, replay, unauthorized) Co-authored-by: Hydra Worker <hydra-worker@example.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
… contract (#3586) Remove the #[cfg(any(test, not(target_arch = "wasm32")))] attribute from the payload module declaration and enable the soroban-sdk "alloc" feature to provide a global allocator for wasm32 builds, allowing alloc::vec::Vec usage in the payload parsing code. Co-authored-by: Hydra Worker <hydra-worker@example.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
) * feat: add end-to-end test script for Pyth Lazer Stellar contracts Add a self-contained bash script that tests the full Lazer price verification flow on Stellar testnet using a real signed update from the Pyth Lazer service. The script fetches a live price update, recovers the signer's public key via ECDSA recovery, deploys a fresh Lazer contract, adds the signer as trusted, and calls verify_update to confirm the real payload verifies successfully. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: rewrite E2E test as proper TypeScript project following repo conventions Replace the inline bash/Node.js script with a proper TypeScript project at lazer/contracts/stellar/scripts/e2e/ with package.json, tsconfig.json, and turbo.json following the pattern of lazer/contracts/sui/sdk/js. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: simplify E2E test and add signer to deploy script per review feedback - deploy.sh now adds the Pyth Lazer trusted signer (hardcoded pubkey) during deployment, so contracts are ready to use immediately - E2E test simplified to only test verify_update against an existing contract address (passed via --contract-id), no redeployment - Removed @noble/curves and @noble/hashes dependencies (no longer needed since pubkey recovery moved to deploy script) - Deployed initialized testnet contract and documented address in README - Verified E2E test passes against the new testnet deployment Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Hydra Worker <hydra-worker@example.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
…deploy (#3588) * feat: enhance Stellar Lazer e2e test with output validation and auto-deploy - Modify Lazer contract `initialize` to accept optional initial trusted signer, fixing deploy.sh which couldn't call `update_trusted_signer` (requires executor contract auth that can't be provided from CLI) - Update deploy.sh to pass initial signer during initialization and build only needed packages (fixes wasm32 build failure on integration-tests crate) - Rewrite e2e test to deploy fresh contracts via deploy.sh instead of requiring pre-existing --contract-id - Add payload parsing and validation: verify magic number, parse timestamp, channel, feed IDs, and price data - Add transaction hash capture from Horizon API with Stellar Explorer link - Force transaction submission with --send=yes for on-chain verification Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: separate deployment from e2e test, require --contract-id The e2e test no longer deploys contracts inline. Instead, it requires a --contract-id flag pointing to a pre-deployed Lazer contract. Deploy separately using deploy.sh first, then run the test against it. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Hydra Worker <hydra-worker@example.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
| /// FIXME: the handling of the guardian sets here is wrong. It needs to expire the current guardian set | ||
| /// and update to the next guardian set, creating the 24h period where both guardian sets are accepted. | ||
| /// Look at the ethereum wormhole contract implementation for a guide to how this should work. |
There was a problem hiding this comment.
🚩 Guardian set upgrade lacks transition period (acknowledged FIXME)
The update_guardian_set function (lib.rs:69-147) immediately replaces the current guardian set with the new one. The standard Wormhole behavior (as implemented in the EVM core bridge) includes a ~24h transition period where both old and new guardian sets are accepted. This is explicitly acknowledged with a FIXME comment at lib.rs:66-68, but it's a significant deviation from the Wormhole protocol that could cause issues during guardian transitions — any pending governance VAAs signed by the old set become immediately invalid after the upgrade.
Was this helpful? React with 👍 or 👎 to provide feedback.
| // TODO: this contract needs upgradability | ||
| } |
There was a problem hiding this comment.
🚩 Wormhole executor contract has no upgrade mechanism
The WormholeExecutor contract has a TODO comment at lib.rs:221 noting it needs upgradability, but no upgrade function is implemented. The Lazer contract has an upgrade function callable by the executor, but the executor itself has no way to be upgraded. If a bug is found in the executor's VAA verification or governance dispatch logic, there's no on-chain mechanism to fix it — the entire system would need to be redeployed with new contract IDs and re-initialized.
Was this helpful? React with 👍 or 👎 to provide feedback.
| fn setup(num_guardians: u8) -> TestEnv<'static> { | ||
| let env = Env::default(); | ||
| env.mock_all_auths(); |
There was a problem hiding this comment.
🚩 Integration tests use mock_all_auths — real auth flow not fully exercised
The integration tests in test.rs call env.mock_all_auths() at test.rs:201, which automatically satisfies all require_auth() checks. While this simplifies testing, it means the critical cross-contract auth flow (executor calling Lazer's update_trusted_signer with implicit contract-to-contract auth) is not fully verified in tests. The unit tests for the Lazer contract (pyth-lazer-stellar/src/test.rs:329-349) do exercise unauthorized access without mock_all_auths, but the integration test path executor→lazer has auth mocked. This is worth noting for deployment validation.
Was this helpful? React with 👍 or 👎 to provide feedback.
| if let (Some(pubkey), Some(expires_at)) = (initial_signer, initial_signer_expires_at) { | ||
| state::set_trusted_signer(&env, &pubkey, expires_at); | ||
| } |
There was a problem hiding this comment.
🟡 Silent failure in initialize when only one of the two optional initial-signer params is provided
When initial_signer is Some(key) but initial_signer_expires_at is None (or vice versa), the if let (Some(pubkey), Some(expires_at)) pattern silently skips adding the signer, and initialize returns Ok(()). Since initialize can only be called once (the AlreadyInitialized guard at line 31), the contract is permanently left without the intended initial signer. The only recourse would be to add the signer via a Wormhole governance VAA through the executor, which may not be available immediately after deployment.
Example of the mismatched call
A deployer accidentally calls:
initialize(executor, Some(pubkey), None)
The executor is stored, Ok(()) is returned, but no signer is added. Re-calling initialize to fix it returns AlreadyInitialized. All verify_update calls will fail with SignerNotTrusted until a governance VAA is processed.
Prompt for agents
In PythLazerContract::initialize (lib.rs:25-39), the two optional parameters initial_signer and initial_signer_expires_at are independently optional, but the function only adds the signer when BOTH are Some. If only one is provided, the signer is silently not added and initialize cannot be called again due to the AlreadyInitialized guard.
Consider one of these approaches:
1. Return an error if exactly one of the two options is Some (invalid input).
2. Change the API to use a single Option<(BytesN<33>, u64)> tuple parameter so both values are always provided together or neither is.
3. At minimum, add a comment documenting that both must be Some for the signer to be added.
Option 2 is the cleanest solution as it makes the mismatched case unrepresentable at the type level.
Was this helpful? React with 👍 or 👎 to provide feedback.
| /// Extend TTL on instance storage (call on every user-facing invocation). | ||
| pub fn extend_instance_ttl(env: &Env) { | ||
| env.storage() | ||
| .instance() | ||
| .extend_ttl(TTL_THRESHOLD, TTL_EXTEND_TO); | ||
| } |
There was a problem hiding this comment.
🚩 No contract WASM code TTL extension — only instance storage TTL is extended
The DESIGN.md at lines 312-313 states both contracts should extend their instance AND code TTLs proactively. The implementation extends instance TTL via env.storage().instance().extend_ttl() in both contracts, and persistent entry TTL for guardian set/signer data. However, the WASM code entry has a separate TTL that is never explicitly extended. If the WASM code entry expires and is archived, contract invocations would fail until the code is restored (Protocol 23 auto-restore would handle this but at extra cost to the caller). For long-lived production contracts, consider adding env.deployer().extend_ttl() or using external CLI-based TTL extension for the code entry.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
this comment is wrong -- the instance().extend_ttl() method covers the code also. see docs here https://developers.stellar.org/docs/build/guides/conventions/extending-wasm-ttl#:~:text=Get%20the%20contract's%20footprint,Sign%20and%20submit%20the%20transaction
| pub fn initialize( | ||
| env: Env, | ||
| executor: Address, | ||
| initial_signer: Option<BytesN<33>>, | ||
| initial_signer_expires_at: Option<u64>, | ||
| ) -> Result<(), ContractError> { | ||
| if state::has_executor(&env) { | ||
| return Err(ContractError::AlreadyInitialized); | ||
| } | ||
| state::set_executor(&env, &executor); | ||
| if let (Some(pubkey), Some(expires_at)) = (initial_signer, initial_signer_expires_at) { | ||
| state::set_trusted_signer(&env, &pubkey, expires_at); | ||
| } | ||
| state::extend_instance_ttl(&env); | ||
| Ok(()) |
There was a problem hiding this comment.
🚩 initialize function has no access control — can be front-run
The initialize function on both contracts has no caller authentication. Anyone who observes the deploy transaction on-chain could front-run the initialization call and set themselves as the executor (for the Lazer contract) or configure a malicious guardian set (for the executor contract). This is a known trade-off with the common Soroban initialization pattern. Mitigation options include using env.deployer().get_caller() to restrict initialization to the deployer, or combining deploy+initialize into a single transaction. For production mainnet deployment, this should be done atomically or in a single block.
Was this helpful? React with 👍 or 👎 to provide feedback.
ali-behjati
left a comment
There was a problem hiding this comment.
overall it looks good but i have two points:
- let's move this off the pyth-crosschain repo to lazer, then you can rely on many types
- parsing is done very poorly (it's very error prone this way), is there any reason why we are not using a parsing library like nom or just serde? are there restrictions on wasm?
| pub fn initialize( | ||
| env: Env, | ||
| executor: Address, | ||
| initial_signer: Option<BytesN<33>>, | ||
| initial_signer_expires_at: Option<u64>, | ||
| ) -> Result<(), ContractError> { | ||
| if state::has_executor(&env) { | ||
| return Err(ContractError::AlreadyInitialized); | ||
| } | ||
| state::set_executor(&env, &executor); | ||
| if let (Some(pubkey), Some(expires_at)) = (initial_signer, initial_signer_expires_at) { | ||
| state::set_trusted_signer(&env, &pubkey, expires_at); | ||
| } | ||
| state::extend_instance_ttl(&env); | ||
| Ok(()) |
| env.storage().instance().set(&DataKey::Executor, executor); | ||
| } | ||
|
|
||
| /// Read the executor address. Panics if not initialized. |
There was a problem hiding this comment.
panicing is not great [we sure the env handles the panics properly?]. Can't it return an option?
| /// TTL threshold: extend when TTL drops below this (approx 6 days at 5s/ledger). | ||
| pub const TTL_THRESHOLD: u32 = 100_000; | ||
| /// TTL extension target (approx 29 days at 5s/ledger). | ||
| pub const TTL_EXTEND_TO: u32 = 500_000; |
There was a problem hiding this comment.
what are these TTLs? Are these costs for storage that we pay? updating every week seems aggressive. can we change it to every year?
| /// Input: 0x04 || x (32 bytes) || y (32 bytes) | ||
| /// Output: parity_byte || x (32 bytes) | ||
| /// where parity_byte = 0x02 if y is even, 0x03 if y is odd | ||
| fn compress_pubkey(env: &Env, uncompressed: &BytesN<65>) -> BytesN<33> { |
There was a problem hiding this comment.
there should be a function out there for this. are we under contract size limitations here?
| fn read_le_u32(data: &Bytes, offset: u32) -> u32 { | ||
| (data.get(offset).unwrap() as u32) | ||
| | ((data.get(offset + 1).unwrap() as u32) << 8) | ||
| | ((data.get(offset + 2).unwrap() as u32) << 16) | ||
| | ((data.get(offset + 3).unwrap() as u32) << 24) | ||
| } |
There was a problem hiding this comment.
These unwraps are dangerous
| let sequence = ((get_byte(data, seq_offset) as u64) << 56) | ||
| | ((get_byte(data, seq_offset + 1) as u64) << 48) | ||
| | ((get_byte(data, seq_offset + 2) as u64) << 40) | ||
| | ((get_byte(data, seq_offset + 3) as u64) << 32) | ||
| | ((get_byte(data, seq_offset + 4) as u64) << 24) | ||
| | ((get_byte(data, seq_offset + 5) as u64) << 16) | ||
| | ((get_byte(data, seq_offset + 6) as u64) << 8) | ||
| | (get_byte(data, seq_offset + 7) as u64); |
Summary
Initial version of Stellar lazer contracts. There are still a few issues here that need to be resolved before we can ship (see inline fixmes / todos), but i would like to get something merged to main that I can work off of.
Rationale
We want this for our Stellar deployment.
How has this been tested?