[ONGOING] OP-PIP-142: Upgrade Pyth Lazer EVM Contracts to v0.3.0 (Mainnet & Testnet)

Abstract

These two proposals upgrade the Pyth Lazer verifier contract (PythLazer) to v0.3.0 on 21 EVM mainnets and 12 EVM testnets. v0.3.0 adds an owner-only setVerificationFee function, so the Pythian Council can change the verification fee through governance.

The fee does not change in this proposal. It stays at 1 wei on every chain. Follow-up proposals will set it to 0.

There are two on-chain proposals, with one instruction per chain:

Each instruction is a Wormhole governance Execute message. On each chain, the Pyth governance executor calls upgradeToAndCall(<v0.3.0 implementation>, 0x) on the Lazer proxy.

Code changes: PR #4060, merged at commit a4d38b1.

Testnet will be performed first then tested. If successful, the mainnet one will be performed.

Rationale

Every EVM Lazer contract charges a verification_fee of 1 wei on each verifyUpdate call. initialize sets this value once. The versions deployed today (0.1.1 and 0.2.0) have no function to change it. Because of this:

  • Every integrator must send value with each verification.
  • On Tempo, which does not allow native value transfers, verifyUpdate cannot be called at all.

v0.3.0 makes the fee a governance parameter:

  • setVerificationFee(uint256 fee) is owner only and emits VerificationFeeSet(oldFee, newFee).
  • withdrawFees(address payable recipient) is owner only. It sends the contract balance (the collected fees) to recipient, emits FeesWithdrawn(recipient, amount) and rejects the zero address.

On each chain, the owner of the Lazer proxy is the Pyth governance executor. Only a Pythian Council proposal can call these functions.

Description

Mainnet: 21 chains

Chain Value
Every chain except Mezo proxy 0xACeA761c27A909d4D3895128EBe6370FDE2dF481, new implementation 0xA089a10d05CE37f4B99Dc4F1824ba253434A779E
Mezo proxy 0x00Aa49132AF596DE135C840E85E4bF6871dB4Eb8, new implementation 0xd74CdD8Eef0E97a5a7678F907991316f88E7965A

The implementations were deployed with CreateX (CREATE2), which gives the same address on every chain. Mezo has no CreateX, so its implementation was deployed with a plain CREATE.

Note that the Mezo implementation address is the same as the Injective EVM executor address. The same deployer account created both contracts at nonce 31, on two different chains.

The table is in instruction order (# = instruction number). The chain names are the contract-manager IDs that check_proposal prints.


#
Chain Chain ID Executor (proxy owner) Current version
1 soneium 1868 0x41c9e39574F40Ad34c79f1C99B66A45eFB830d4c 0.1.1
2 base 8453 0xf0a1b566B55e0A0CB5BeF52Eb2a57142617Bee67 0.1.1
3 tempo 4217 0x98046Bd286715D3B0BC227Dd7a956b83D8978603 0.1.1
4 megaeth 4326 0x5744Cbf430D99456a0A8771208b674F27f8EF0Fb 0.1.1
5 polygon 137 0xEbe57e8045F2F230872523bbff7374986E45C486 0.1.1
6 arbitrum 42161 0x24654078A8E043e8985D962a5100CDfA2026f92C 0.1.1
7 bsc 56 0x26DD80569a8B23768A1d80869Ed7339e07595E85 0.1.1
8 fluent 25363 0x23f0e8FAeE7bbb405E7A7C3d60138FCfd43d7509 0.1.1
9 monad 143 0x26DD80569a8B23768A1d80869Ed7339e07595E85 0.1.1
10 fantom_sonic_mainnet 146 0x5744Cbf430D99456a0A8771208b674F27f8EF0Fb 0.1.1
11 injective_evm 1776 0xd74CdD8Eef0E97a5a7678F907991316f88E7965A 0.1.1
12 berachain_mainnet 80094 0x5744Cbf430D99456a0A8771208b674F27f8EF0Fb 0.1.1
13 hyperevm 999 0x5D289Ad1CE59fCC25b6892e7A303dfFf3a9f7167 0.1.1
14 cronos 25 0xDd24F84d36BF92C65F92307595335bdFab5Bbd21 0.1.1
15 mezo 31612 0xEbe57e8045F2F230872523bbff7374986E45C486 0.2.0
16 robinhood 4663 0x0708325268dF9F66270F1401206434524814508b 0.2.0
17 flow_mainnet 747 0x26DD80569a8B23768A1d80869Ed7339e07595E85 0.2.0
18 etherlink 42793 0x98046Bd286715D3B0BC227Dd7a956b83D8978603 0.2.0
19 sei_evm_mainnet 1329 0x74f09cb3c7e2A01865f424FD14F6dc9A14E3e94E 0.2.0
20 ethereum 1 0xCcD7e24e71ba746CBc758C39CBAb11f4E0dfc46F 0.2.0
21 arc 5042 0x0708325268dF9F66270F1401206434524814508b 0.2.0

Testnet: 12 chains

Chain Value
Every chain except Mezo testnet proxy 0xACeA761c27A909d4D3895128EBe6370FDE2dF481, new implementation 0xA089a10d05CE37f4B99Dc4F1824ba253434A779E
Mezo testnet proxy 0x768dbf6a56b2C16b68C561a1A3395e2e86542D97, new implementation 0xF33dc56d1073870936E6206E0eE5e77D9F14BFA4
# Chain Chain ID Executor (proxy owner) Current version
1 soneium_minato_testnet 1946 0x98046Bd286715D3B0BC227Dd7a956b83D8978603 0.1.1
2 optimism_sepolia 11155420 0x549Ebba8036Ab746611B4fFA1423eb0A4Df61440 0.1.1
3 base_sepolia 84532 0x23f0e8FAeE7bbb405E7A7C3d60138FCfd43d7509 0.1.1
4 arbitrum_sepolia 421614 0x9DF02366A266D79DA5E978aDBEe8B8502117ee1a 0.1.1
5 arc_testnet 5042002 0x36825bf3Fbdf5a29E2d5148bfe7Dcf7B5639e320 0.1.1
6 tempo_testnet 42431 0x8D254a21b3C86D32F7179855531CE99164721933 0.1.1
7 bsc_testnet 97 0xD458261E832415CFd3BAE5E416FdF3230ce6F134 0.1.1
8 monad_testnet 10143 0x4374e5a8b9C22271E9EB878A2AA31DE97DF15DAF 0.1.1
9 injective_evm_testnet 1439 0x9DF02366A266D79DA5E978aDBEe8B8502117ee1a 0.1.1
10 mezo_testnet 31611 0xA5F1987F5b922f7730d709F9DC725cFfB1BF5E9a 0.2.0
11 cronos_testnet 338 0x4374e5a8b9C22271E9EB878A2AA31DE97DF15DAF 0.2.0
12 robinhood_testnet 46630 0x0708325268dF9F66270F1401206434524814508b

Not included

Chain Network Reason
ethereal Mainnet The proxy owner is not a governance executor that can execute this proposal. This chain will be handled separately.
polynomial Mainnet No working RPC endpoint when this proposal was written.
sepolia Testnet The proxy runs an early 0.1.1 build that never wrote the signer-expiry mapping. v0.3.0 reads that mapping, so after the upgrade it would reject Sepolia’s only trusted signer. Sepolia will be upgraded in a separate proposal, together with a trusted-signer update.
sonic_blaze_testnet, polynomial_testnet, ethereal_devnet, ethereal_testnet_v2 Testnet No working RPC endpoint when this proposal was written.

Execution order

  1. The testnet proposal executes first.
  2. The mainnet proposal is relayed only after the testnet upgrades are confirmed on-chain.

What changes for integrators

No changes

  • The inputs and outputs of verifyUpdate. For the same update, every chain returns a byte-identical (payload, signer) before and after the upgrade. See Testing.
  • The fee. It stays at 1 wei until a later proposal changes it.
  • Signature recovery (ECDSA.tryRecover), signer-expiry checks and the trusted-signer set.
  • The owner and the storage layout. The upgrade calls no initializer (the call data is empty), so all existing state stays.
  • Upgradeability. The proxy stays UUPS-upgradeable by the same owner.

Changes

  • Refund of an overpayment.
    • Before: the contract refunded any value above the fee with transfer, before it verified the update. If the caller could not receive the refund, the whole call reverted.
    • Now: the contract refunds with send after it verifies the update. send has the same 2,300-gas stipend. If the refund fails, the call still succeeds and emits RefundFailed(payee, amount). The excess stays in the contract, and governance can withdraw it with withdrawFees.
    • Callers that pay exactly the fee are not affected.
  • Gas. verifyUpdate uses 58–78 more gas per call. A call that overpays the fee needs about 1.9k more gas available. The gas it actually uses does not increase.
  • New functions on the 14 mainnets that run 0.1.1 today. getTrustedSigners() and getTrustedSignerExpiry(address) become available. Version 0.2.0 added them.

Before the follow-up proposal

The follow-up proposal will set the fee to 0. Before then, integrations that hard-code msg.value = 1 wei, or that require msg.value == fee, should read verification_fee() at call time.

Testing

1. Fork tests.

  • We forked each chain locally and executed the upgrade as that chain’s executor.
  • We verified real Pyth Lazer updates before and after the upgrade. The 6 update types were:
    • a single feed
    • several feeds
    • 40 feeds
    • a funding-rate feed
    • the 200ms channel, the 1s channel and the real-time channel
  • The results were byte-identical.
  • The upgrade changed only the implementation slot in storage.
  • The owner could still upgrade the proxy and roll it back.

2. Live checks on each chain’s own RPC. We sent eth_call requests with the implementation slot overridden to v0.3.0. For fresh Lazer updates, the results were identical to the current contract, at the current fee and with the fee set to 0.

Scope

This proposal authorizes: the 33 contract upgrades listed above.

This proposal does not authorize:

  • any change to the verification fee
  • any fee withdrawal
  • any change to trusted signers
  • any other contract change

Onchain verification

1. Decode the proposals.

Requirement: Node.js 24 (see .nvmrc).

git clone https://github.com/pyth-network/pyth-crosschain.git
cd pyth-crosschain
pnpm install && pnpm turbo build --filter @pythnetwork/contract-manager
cd contract_manager

pnpm tsx scripts/check_proposal.ts --cluster mainnet-beta --contract-type lazer \
  --proposal Ae2hALypthvCcuKbdVeDAD8Xx3yeebqkynuD8YLCYJfw

pnpm tsx scripts/check_proposal.ts --cluster mainnet-beta --contract-type lazer \
  --proposal AC7ShDLj5zUvsqgtGSnWVBZkg4LwGJUVrdCH4d1otTKj

For each instruction, the output shows:

  • the call: upgradeToAndCall(<implementation>, 0x) on EvmLazerContract at the chain’s proxy, from the chain’s executor
  • the code digest of the new implementation

Check these points:

  • Every one of the 33 digests is 0xb4e6083b748ecb185c4be398a87e364be836638f047bc69f28d80e5e57fcead7. The digest is calculated with the implementation’s own address replaced by zeros, because UUPS stores that address as an immutable. This is why the Mezo implementations, which have different addresses, give the same digest.
  • Each proxy’s on-chain owner() is the executor in the instruction. If an owner is different, check_proposal prints a line that starts with Executor Address: ... is not equal to Owner Address.
  • Each run ends with No problems found.

2. Check the current state of each proxy.

Run this from contract_manager. It takes the RPC URLs and proxy addresses from the repository’s store. For the testnets, replace the chain list with the 12 testnet IDs.

SIGNER=0x26FB61A864c758AE9fBA027a96010480658385B9   # signer of current Pyth Lazer EVM updates
for c in soneium base tempo megaeth polygon arbitrum bsc fluent monad fantom_sonic_mainnet injective_evm berachain_mainnet hyperevm cronos mezo robinhood flow_mainnet etherlink sei_evm_mainnet ethereum arc; do
  rpc=$(jq -r --arg c "$c" '.[] | select(.id == $c) | .rpcUrl' src/store/chains/EvmChains.json)
  proxy=$(jq -r --arg c "$c" '.[] | select(.chain == $c) | .address' src/store/contracts/EvmLazerContracts.json)
  echo "$c impl=$(cast implementation $proxy --rpc-url $rpc)" \
    "version=$(cast call $proxy 'version()(string)' --rpc-url $rpc)" \
    "fee=$(cast call $proxy 'verification_fee()(uint256)' --rpc-url $rpc)" \
    "signer=$(cast call $proxy 'isValidSigner(address)(bool)' $SIGNER --rpc-url $rpc)" \
    "owner=$(cast call $proxy 'owner()(address)' --rpc-url $rpc)"
done

Expected result before execution, on every chain:

  • version is the current version in the tables above.
  • fee=1
  • signer=true
  • owner is the executor in the tables above.

3. Check the source verification.

The implementation source is verified as an exact match on Sourcify on every chain except Fluent:

Sourcify does not support Fluent. The Fluent implementation is fully verified on Fluentscan.

4. Build the implementation from source and compare the digest.

git checkout a4d38b18550067d79972c94eb31abce2a6c3efc2
git submodule update --init --recursive -- lazer/contracts/evm
foundryup --install 0.3.0
cd lazer/contracts/evm
forge build
jq -r .deployedBytecode.object out/PythLazer.sol/PythLazer.json | tr -d '\r\n' | cast keccak
# 0xb4e6083b748ecb185c4be398a87e364be836638f047bc69f28d80e5e57fcead7

The result must equal the digest from step 1.

  • Use --recursive. Forge writes the remappings of the nested submodules into the contract metadata, and that metadata is part of the bytecode.
  • Use Foundry 0.3.0. This is the version that built the deployed implementations. Newer versions, for example 1.8.5, build the same executable code. They write different remappings into the metadata, so the metadata hash at the end of the bytecode is different and the digest does not match.

5. After execution.

  1. Run git checkout main.
  2. Run step 2 again.
  3. On every chain, the expected result is:
    • impl=0xA089a10d05CE37f4B99Dc4F1824ba253434A779E. On Mezo it is 0xd74CdD8Eef0E97a5a7678F907991316f88E7965A. On Mezo testnet it is 0xF33dc56d1073870936E6206E0eE5e77D9F14BFA4.
    • version="0.3.0"
    • fee=1
    • signer=true
    • owner is the same as before.

References

Proposal IDs

1 Like