Skip to content

feat(stellar): Initial version of Stellar Lazer contracts#3612

Open
jayantk wants to merge 20 commits intomainfrom
stellar
Open

feat(stellar): Initial version of Stellar Lazer contracts#3612
jayantk wants to merge 20 commits intomainfrom
stellar

Conversation

@jayantk
Copy link
Copy Markdown
Contributor

@jayantk jayantk commented Apr 16, 2026

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?

  • Current tests cover my changes
  • Added new tests
  • Manually tested the code

Open with Devin

jayantk and others added 15 commits March 27, 2026 19:48
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>
@jayantk jayantk requested a review from a team as a code owner April 16, 2026 18:09
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 16, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
api-reference Ready Ready Preview, Comment Apr 17, 2026 2:53pm
component-library Ready Ready Preview, Comment Apr 17, 2026 2:53pm
developer-hub Error Error Apr 17, 2026 2:53pm
entropy-explorer Error Error Apr 17, 2026 2:53pm
insights Error Error Apr 17, 2026 2:53pm
proposals Error Error Apr 17, 2026 2:53pm
staking Ready Ready Preview, Comment Apr 17, 2026 2:53pm

Request Review

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 5 new potential issues.

View 9 additional findings in Devin Review.

Open in Devin Review

Comment thread lazer/contracts/stellar/scripts/deploy.sh Outdated
Comment on lines +66 to +68
/// 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.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚩 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.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +221 to +222
// TODO: this contract needs upgradability
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚩 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.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +199 to +201
fn setup(num_guardians: u8) -> TestEnv<'static> {
let env = Env::default();
env.mock_all_auths();
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot Apr 16, 2026

Choose a reason for hiding this comment

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

🚩 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.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 3 new potential issues.

View 11 additional findings in Devin Review.

Open in Devin Review

Comment on lines +35 to +37
if let (Some(pubkey), Some(expires_at)) = (initial_signer, initial_signer_expires_at) {
state::set_trusted_signer(&env, &pubkey, expires_at);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 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.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +58 to +63
/// 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);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚩 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.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Comment on lines +25 to +39
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(())
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚩 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.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@jayantk can you add a todo for ^?

Copy link
Copy Markdown
Collaborator

@ali-behjati ali-behjati left a comment

Choose a reason for hiding this comment

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

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?

Comment on lines +25 to +39
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(())
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@jayantk can you add a todo for ^?

env.storage().instance().set(&DataKey::Executor, executor);
}

/// Read the executor address. Panics if not initialized.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

panicing is not great [we sure the env handles the panics properly?]. Can't it return an option?

Comment on lines +3 to +6
/// 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;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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> {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

there should be a function out there for this. are we under contract size limitations here?

Comment on lines +100 to +105
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)
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

These unwraps are dangerous

Comment on lines +136 to +143
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);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

this is strange.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants