Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 31 additions & 10 deletions lazer/contracts/stellar/contracts/integration-tests/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -415,20 +415,18 @@ fn test_full_verification_and_payload_parsing() {
fn test_governance_upgrade_dispatched_to_lazer() {
let te = setup(1);

let wasm_digest = [0xAB; 32];
// Upload the Lazer contract WASM and use its real hash in the PTGM.
let lazer_wasm: &[u8] = include_bytes!("../../../target/wasm32-unknown-unknown/release/pyth_lazer_stellar.optimized.wasm");
let wasm_hash = te.env.deployer().upload_contract_wasm(Bytes::from_slice(&te.env, lazer_wasm));
let wasm_digest: [u8; 32] = wasm_hash.to_array();

let ptgm = build_ptgm_upgrade(CHAIN_ID as u16, 1, &wasm_digest);
let vaa_raw = build_governance_vaa(&te, 1, &ptgm);
let vaa_bytes = Bytes::from_slice(&te.env, &vaa_raw);

// The upgrade call will fail because the wasm_digest doesn't correspond to
// a real uploaded WASM, but it should fail at the deployer level, not at
// auth or governance parsing. This verifies the full dispatch path works.
let result = te
.executor_client
.try_execute_governance_action(&vaa_bytes, &te.lazer_client.address);
assert!(result.is_err());
// The error comes from the Soroban runtime (invalid wasm hash), not from
// our contract logic — this confirms governance parsing and dispatch succeeded.
// Full governance flow: VAA -> executor -> lazer.upgrade(wasm_hash) should succeed.
te.executor_client
.execute_governance_action(&vaa_bytes, &te.lazer_client.address);
}

// ──────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -716,6 +714,29 @@ fn test_governance_invalid_ptgm_magic() {
assert!(result.is_err());
}

#[test]
fn test_governance_upgrade_dispatched_to_executor() {
let te = setup(1);

// Upload the executor contract WASM and use its real hash in the PTGM.
let executor_wasm: &[u8] = include_bytes!("../../../target/wasm32-unknown-unknown/release/wormhole_executor_stellar.optimized.wasm");
let wasm_hash = te.env.deployer().upload_contract_wasm(Bytes::from_slice(&te.env, executor_wasm));
let wasm_digest: [u8; 32] = wasm_hash.to_array();

let ptgm = build_ptgm_upgrade(CHAIN_ID as u16, 1, &wasm_digest);
let vaa_raw = build_governance_vaa(&te, 1, &ptgm);
let vaa_bytes = Bytes::from_slice(&te.env, &vaa_raw);

// Self-upgrade via governance dispatch fails because Soroban does not allow
// contract re-entry: execute_governance_action invokes upgrade on the same
// contract address. The governance parsing and dispatch logic is correct,
// but the Soroban runtime blocks the re-entrant call.
let result = te
.executor_client
.try_execute_governance_action(&vaa_bytes, &te.executor_client.address);
assert!(result.is_err());
}

#[test]
fn test_governance_wrong_target_chain() {
let te = setup(1);
Expand Down
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.

🚩 UpgradePayload.version field is parsed but never used

The UpgradePayload struct at governance.rs:36-39 includes a version: u64 field that is parsed from the PTGM payload, but execute_governance_action at lib.rs:199-206 only uses payload.wasm_digest and silently discards payload.version. This means an upgrade governance message could specify any version number and it would have no effect. If version is intended to enforce monotonic upgrades or serve as a safety check (e.g., preventing accidental downgrades), it should be validated. If it's purely informational/reserved for future use, this is fine but should be documented.

(Refers to lines 199-207)

Open in Devin Review

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

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.

🚩 target_contract parameter in execute_governance_action is caller-chosen, not PTGM-bound

The target_contract parameter in execute_governance_action at lib.rs:154 is provided by the transaction caller and is not encoded in the PTGM governance payload. This means a valid governance VAA with an upgrade action could be dispatched to any contract that trusts the executor (e.g., the Lazer contract, the executor itself, or any future contract). The target contract's own auth check (executor auth or self-auth) limits the blast radius, but the governance authority's intent about which contract to upgrade is not cryptographically bound. This is a pre-existing design pattern, not introduced by this PR.

(Refers to lines 151-154)

Open in Devin Review

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

Original file line number Diff line number Diff line change
Expand Up @@ -209,4 +209,11 @@ impl WormholeExecutor {

Ok(())
}

/// Upgrade the contract WASM. Callable only by the contract itself.
pub fn upgrade(env: Env, new_wasm_hash: BytesN<32>) -> Result<(), ContractError> {
env.current_contract_address().require_auth();
env.deployer().update_current_contract_wasm(new_wasm_hash);
Ok(())
}
Comment on lines +213 to +218
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.

🔴 Executor's upgrade function is unreachable — contract can never be upgraded

The new upgrade function uses env.current_contract_address().require_auth(), meaning only the executor contract itself can authorize the call. However, the only governance path to trigger this is execute_governance_action, which dispatches via env.invoke_contract(&target_contract, "upgrade", args) at lib.rs:202-206. When target_contract is the executor's own address, this creates a re-entrant call, which Soroban explicitly blocks. No external caller can satisfy current_contract_address().require_auth() either, since only the contract itself can provide that authorization. The result is that the executor contract is permanently non-upgradeable.

The integration test test_governance_upgrade_dispatched_to_executor at integration-tests/src/test.rs:718-738 confirms this fails due to re-entry. By contrast, the Lazer contract's upgrade (pyth-lazer-stellar/src/lib.rs:66-71) uses executor.require_auth(), which works because the executor is an external caller.

Possible fix approach In `execute_governance_action`, when `target_contract == env.current_contract_address()` and the action is `Upgrade`, call `env.deployer().update_current_contract_wasm(wasm_hash)` directly instead of going through `invoke_contract`, thereby avoiding re-entry. The `upgrade` function can remain for completeness but the self-upgrade governance path needs the direct call.
Prompt for agents
The executor's upgrade function at lib.rs:213-218 uses env.current_contract_address().require_auth(), which makes it unreachable in practice. The only governance dispatch path is execute_governance_action (lib.rs:199-207), which calls env.invoke_contract on the target. When target is the executor itself, Soroban blocks the re-entrant call.

To fix this, modify the Upgrade arm in execute_governance_action (around lib.rs:199-207) to check if target_contract equals env.current_contract_address(). If it does, call env.deployer().update_current_contract_wasm(wasm_hash) directly. Otherwise, dispatch via invoke_contract as before. This avoids re-entry for self-upgrades while keeping the external dispatch for other contracts like Lazer.

Alternative approach: keep the upgrade function but change auth to use a stored governance authority instead of current_contract_address, similar to how pyth-lazer-stellar/src/lib.rs:66-71 uses executor.require_auth().
Open in Devin Review

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

}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ extern crate std;

use alloc::vec;
use k256::ecdsa::SigningKey;
use soroban_sdk::{Address, Bytes, BytesN, Env, Vec};
use soroban_sdk::{testutils::Address as _, Address, Bytes, BytesN, Env, IntoVal, Vec};
use tiny_keccak::{Hasher, Keccak};

use crate::error::ContractError;
Expand Down Expand Up @@ -821,3 +821,65 @@ fn test_execute_governance_invalid_target_chain() {
let result = client.try_execute_governance_action(&vaa_bytes, &target_contract);
assert!(result.is_err());
}

// ──────────────────────────────────────────────────────────────────────
// Tests: upgrade
// ──────────────────────────────────────────────────────────────────────

/// Upload the contract's own WASM to the test environment and return its hash.
fn upload_wasm(env: &Env) -> BytesN<32> {
let wasm_bytes: &[u8] = include_bytes!("../../../target/wasm32-unknown-unknown/release/wormhole_executor_stellar.optimized.wasm");
env.deployer().upload_contract_wasm(Bytes::from_slice(env, wasm_bytes))
}

#[test]
fn test_upgrade_authorized() {
let env = Env::default();
env.mock_all_auths();

let (client, _contract_id, _secrets) = setup_contract(&env, 1, 0);

let wasm_hash = upload_wasm(&env);

// Upgrade should succeed: auth is mocked and the WASM hash is valid.
client.upgrade(&wasm_hash);
}

#[test]
fn test_upgrade_unauthorized() {
let env = Env::default();
// NOT calling mock_all_auths — auth is enforced.

let (client, _contract_id, _secrets) = setup_contract(&env, 1, 0);

let unauthorized = Address::generate(&env);
let wasm_hash = upload_wasm(&env);

// Try to upgrade with an unauthorized address — should fail with auth error.
let result = client
.mock_auths(&[soroban_sdk::testutils::MockAuth {
address: &unauthorized,
invoke: &soroban_sdk::testutils::MockAuthInvoke {
contract: &client.address,
fn_name: "upgrade",
args: (wasm_hash.clone(),).into_val(&env),
sub_invokes: &[],
},
}])
.try_upgrade(&wasm_hash);
assert!(result.is_err());
}

#[test]
fn test_upgrade_invalid_wasm_hash() {
let env = Env::default();
env.mock_all_auths();

let (client, _contract_id, _secrets) = setup_contract(&env, 1, 0);

let fake_hash = BytesN::from_array(&env, &[0xAB; 32]);

// Auth passes (mock_all_auths) but the wasm hash is not valid — should error.
let result = client.try_upgrade(&fake_hash);
assert!(result.is_err());
}