diff --git a/governance/xc_admin/packages/xc_admin_common/src/__tests__/GovernancePayload.test.ts b/governance/xc_admin/packages/xc_admin_common/src/__tests__/GovernancePayload.test.ts index 721a78c468..38eaa9c971 100644 --- a/governance/xc_admin/packages/xc_admin_common/src/__tests__/GovernancePayload.test.ts +++ b/governance/xc_admin/packages/xc_admin_common/src/__tests__/GovernancePayload.test.ts @@ -32,6 +32,7 @@ import { DataSource, SetDataSources, } from "../governance_payload/SetDataSources"; +import { SetTransactionFee } from "../governance_payload/SetTransactionFee"; test("GovernancePayload ser/de", (done) => { jest.setTimeout(60000); @@ -424,6 +425,12 @@ function governanceActionArb(): Arbitrary { Buffer.from(token), ); }); + } else if (header.action === "SetTransactionFee") { + return fc + .record({ v: fc.bigUintN(64), e: fc.bigUintN(64) }) + .map(({ v, e }) => { + return new SetTransactionFee(header.targetChainId, v, e); + }); } else { throw new Error("Unsupported action type"); } diff --git a/governance/xc_admin/packages/xc_admin_common/src/governance_payload/PythGovernanceAction.ts b/governance/xc_admin/packages/xc_admin_common/src/governance_payload/PythGovernanceAction.ts index c6f5d60280..85d1dc4de2 100644 --- a/governance/xc_admin/packages/xc_admin_common/src/governance_payload/PythGovernanceAction.ts +++ b/governance/xc_admin/packages/xc_admin_common/src/governance_payload/PythGovernanceAction.ts @@ -16,6 +16,7 @@ export const TargetAction = { RequestGovernanceDataSourceTransfer: 5, SetWormholeAddress: 6, SetFeeInToken: 7, + SetTransactionFee: 8, } as const; export const EvmExecutorAction = { @@ -46,6 +47,8 @@ export function toActionName( return "SetWormholeAddress"; case 7: return "SetFeeInToken"; + case 8: + return "SetTransactionFee"; } } else if ( deserialized.moduleId == MODULE_EVM_EXECUTOR && diff --git a/governance/xc_admin/packages/xc_admin_common/src/governance_payload/SetTransactionFee.ts b/governance/xc_admin/packages/xc_admin_common/src/governance_payload/SetTransactionFee.ts new file mode 100644 index 0000000000..71b29c97fa --- /dev/null +++ b/governance/xc_admin/packages/xc_admin_common/src/governance_payload/SetTransactionFee.ts @@ -0,0 +1,44 @@ +import { PythGovernanceActionImpl } from "./PythGovernanceAction"; +import * as BufferLayout from "@solana/buffer-layout"; +import * as BufferLayoutExt from "./BufferLayoutExt"; +import { ChainName } from "../chains"; + +/** Set the transaction fee on the target chain to newFeeValue * 10^newFeeExpo */ +export class SetTransactionFee extends PythGovernanceActionImpl { + static layout: BufferLayout.Structure< + Readonly<{ newFeeValue: bigint; newFeeExpo: bigint }> + > = BufferLayout.struct([ + BufferLayoutExt.u64be("newFeeValue"), + BufferLayoutExt.u64be("newFeeExpo"), + ]); + + constructor( + targetChainId: ChainName, + readonly newFeeValue: bigint, + readonly newFeeExpo: bigint, + ) { + super(targetChainId, "SetTransactionFee"); + } + + static decode(data: Buffer): SetTransactionFee | undefined { + const decoded = PythGovernanceActionImpl.decodeWithPayload( + data, + "SetTransactionFee", + SetTransactionFee.layout, + ); + if (!decoded) return undefined; + + return new SetTransactionFee( + decoded[0].targetChainId, + decoded[1].newFeeValue, + decoded[1].newFeeExpo, + ); + } + + encode(): Buffer { + return super.encodeWithPayload(SetTransactionFee.layout, { + newFeeValue: this.newFeeValue, + newFeeExpo: this.newFeeExpo, + }); + } +} diff --git a/governance/xc_admin/packages/xc_admin_common/src/governance_payload/index.ts b/governance/xc_admin/packages/xc_admin_common/src/governance_payload/index.ts index 9015fe6e1a..cb423946a4 100644 --- a/governance/xc_admin/packages/xc_admin_common/src/governance_payload/index.ts +++ b/governance/xc_admin/packages/xc_admin_common/src/governance_payload/index.ts @@ -20,6 +20,7 @@ import { StarknetSetWormholeAddress, } from "./SetWormholeAddress"; import { EvmExecute } from "./ExecuteAction"; +import { SetTransactionFee } from "./SetTransactionFee"; /** Decode a governance payload */ export function decodeGovernancePayload( @@ -73,6 +74,8 @@ export function decodeGovernancePayload( } case "Execute": return EvmExecute.decode(data); + case "SetTransactionFee": + return SetTransactionFee.decode(data); default: return undefined; } @@ -86,5 +89,6 @@ export * from "./GovernanceDataSourceTransfer"; export * from "./SetDataSources"; export * from "./SetValidPeriod"; export * from "./SetFee"; +export * from "./SetTransactionFee"; export * from "./SetWormholeAddress"; export * from "./ExecuteAction"; diff --git a/target_chains/ethereum/contracts/contracts/pyth/Pyth.sol b/target_chains/ethereum/contracts/contracts/pyth/Pyth.sol index 3c17bb0ab7..4e7be0bbff 100644 --- a/target_chains/ethereum/contracts/contracts/pyth/Pyth.sol +++ b/target_chains/ethereum/contracts/contracts/pyth/Pyth.sol @@ -86,7 +86,11 @@ abstract contract Pyth is // In the accumulator update data a single update can contain // up to 255 messages and we charge a singleUpdateFee per each // message - return 255 * singleUpdateFeeInWei() * updateDataSize; + return + 255 * + singleUpdateFeeInWei() * + updateDataSize + + transactionFeeInWei(); } function getUpdateFee( @@ -330,7 +334,8 @@ abstract contract Pyth is function getTotalFee( uint totalNumUpdates ) private view returns (uint requiredFee) { - return totalNumUpdates * singleUpdateFeeInWei(); + return + (totalNumUpdates * singleUpdateFeeInWei()) + transactionFeeInWei(); } function findIndexOfPriceId( @@ -392,6 +397,6 @@ abstract contract Pyth is } function version() public pure returns (string memory) { - return "1.4.3"; + return "1.4.4-alpha.1"; } } diff --git a/target_chains/ethereum/contracts/contracts/pyth/PythGetters.sol b/target_chains/ethereum/contracts/contracts/pyth/PythGetters.sol index 31af2292e2..e0146da190 100644 --- a/target_chains/ethereum/contracts/contracts/pyth/PythGetters.sol +++ b/target_chains/ethereum/contracts/contracts/pyth/PythGetters.sol @@ -91,4 +91,8 @@ contract PythGetters is PythState { function governanceDataSourceIndex() public view returns (uint32) { return _state.governanceDataSourceIndex; } + + function transactionFeeInWei() public view returns (uint) { + return _state.transactionFeeInWei; + } } diff --git a/target_chains/ethereum/contracts/contracts/pyth/PythGovernance.sol b/target_chains/ethereum/contracts/contracts/pyth/PythGovernance.sol index 31c4a52ae6..5bb1bd3265 100644 --- a/target_chains/ethereum/contracts/contracts/pyth/PythGovernance.sol +++ b/target_chains/ethereum/contracts/contracts/pyth/PythGovernance.sol @@ -38,6 +38,7 @@ abstract contract PythGovernance is address oldWormholeAddress, address newWormholeAddress ); + event TransactionFeeSet(uint oldFee, uint newFee); function verifyGovernanceVM( bytes memory encodedVM @@ -97,6 +98,8 @@ abstract contract PythGovernance is parseSetWormholeAddressPayload(gi.payload), encodedVM ); + } else if (gi.action == GovernanceAction.SetTransactionFee) { + setTransactionFee(parseSetTransactionFeePayload(gi.payload)); } else { revert PythErrors.InvalidGovernanceMessage(); } @@ -243,4 +246,13 @@ abstract contract PythGovernance is emit WormholeAddressSet(oldWormholeAddress, address(wormhole())); } + + function setTransactionFee( + SetTransactionFeePayload memory payload + ) internal { + uint oldFee = transactionFeeInWei(); + setTransactionFeeInWei(payload.newFee); + + emit TransactionFeeSet(oldFee, transactionFeeInWei()); + } } diff --git a/target_chains/ethereum/contracts/contracts/pyth/PythGovernanceInstructions.sol b/target_chains/ethereum/contracts/contracts/pyth/PythGovernanceInstructions.sol index 8efc8acc5b..b4a5f3db4f 100644 --- a/target_chains/ethereum/contracts/contracts/pyth/PythGovernanceInstructions.sol +++ b/target_chains/ethereum/contracts/contracts/pyth/PythGovernanceInstructions.sol @@ -34,7 +34,8 @@ contract PythGovernanceInstructions { SetFee, // 3 SetValidPeriod, // 4 RequestGovernanceDataSourceTransfer, // 5 - SetWormholeAddress // 6 + SetWormholeAddress, // 6 + SetTransactionFee // 7 } struct GovernanceInstruction { @@ -77,6 +78,10 @@ contract PythGovernanceInstructions { address newWormholeAddress; } + struct SetTransactionFeePayload { + uint newFee; + } + /// @dev Parse a GovernanceInstruction function parseGovernanceInstruction( bytes memory encodedInstruction @@ -220,4 +225,22 @@ contract PythGovernanceInstructions { if (encodedPayload.length != index) revert PythErrors.InvalidGovernanceMessage(); } + + /// @dev Parse a SetTransactionFeePayload (action 7) with minimal validation + function parseSetTransactionFeePayload( + bytes memory encodedPayload + ) public pure returns (SetTransactionFeePayload memory stf) { + uint index = 0; + + uint64 val = encodedPayload.toUint64(index); + index += 8; + + uint64 expo = encodedPayload.toUint64(index); + index += 8; + + stf.newFee = uint256(val) * uint256(10) ** uint256(expo); + + if (encodedPayload.length != index) + revert PythErrors.InvalidGovernanceMessage(); + } } diff --git a/target_chains/ethereum/contracts/contracts/pyth/PythSetters.sol b/target_chains/ethereum/contracts/contracts/pyth/PythSetters.sol index 6da001ee07..849fc7659f 100644 --- a/target_chains/ethereum/contracts/contracts/pyth/PythSetters.sol +++ b/target_chains/ethereum/contracts/contracts/pyth/PythSetters.sol @@ -48,4 +48,8 @@ contract PythSetters is PythState, IPythEvents { function setGovernanceDataSourceIndex(uint32 newIndex) internal { _state.governanceDataSourceIndex = newIndex; } + + function setTransactionFeeInWei(uint fee) internal { + _state.transactionFeeInWei = fee; + } } diff --git a/target_chains/ethereum/contracts/contracts/pyth/PythState.sol b/target_chains/ethereum/contracts/contracts/pyth/PythState.sol index 6829cf9ba1..a860a4341b 100644 --- a/target_chains/ethereum/contracts/contracts/pyth/PythState.sol +++ b/target_chains/ethereum/contracts/contracts/pyth/PythState.sol @@ -38,6 +38,8 @@ contract PythStorage { // Mapping of cached price information // priceId => PriceInfo mapping(bytes32 => PythInternalStructs.PriceInfo) latestPriceInfo; + // Fee charged per transaction, in addition to per-update fees + uint transactionFeeInWei; } } diff --git a/target_chains/ethereum/contracts/forge-test/PythGovernance.t.sol b/target_chains/ethereum/contracts/forge-test/PythGovernance.t.sol new file mode 100644 index 0000000000..fa83593232 --- /dev/null +++ b/target_chains/ethereum/contracts/forge-test/PythGovernance.t.sol @@ -0,0 +1,614 @@ +// SPDX-License-Identifier: Apache 2 + +// NOTE: These tests were migrated from target_chains/ethereum/contracts/test/pyth.js but exclude the Wormhole-specific tests, +// which remain in the original JavaScript test file. + +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import "forge-std/Test.sol"; + +import "@pythnetwork/pyth-sdk-solidity/IPyth.sol"; +import "@pythnetwork/pyth-sdk-solidity/PythErrors.sol"; +import "@pythnetwork/pyth-sdk-solidity/PythStructs.sol"; +import "../contracts/pyth/PythInternalStructs.sol"; +import "../contracts/pyth/PythGovernanceInstructions.sol"; +import "../contracts/pyth/PythUpgradable.sol"; +import "../contracts/pyth/PythGetters.sol"; +import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import "../contracts/wormhole/interfaces/IWormhole.sol"; +import "../contracts/wormhole/Implementation.sol"; +import "../contracts/wormhole/Setup.sol"; +import "../contracts/wormhole/Wormhole.sol"; +import "../contracts/wormhole-receiver/WormholeReceiver.sol"; +import "../contracts/wormhole-receiver/ReceiverImplementation.sol"; +import "../contracts/wormhole-receiver/ReceiverSetup.sol"; +import "../contracts/wormhole-receiver/ReceiverGovernanceStructs.sol"; +import "../contracts/wormhole-receiver/ReceiverStructs.sol"; +import "../contracts/wormhole-receiver/ReceiverGovernance.sol"; +import "../contracts/libraries/external/BytesLib.sol"; +import "../contracts/pyth/mock/MockUpgradeableProxy.sol"; +import "./utils/WormholeTestUtils.t.sol"; +import "./utils/PythTestUtils.t.sol"; +import "./utils/RandTestUtils.t.sol"; + +contract PythGovernanceTest is + Test, + WormholeTestUtils, + PythTestUtils, + PythGovernanceInstructions +{ + using BytesLib for bytes; + + IPyth public pyth; + address constant TEST_SIGNER1 = 0xbeFA429d57cD18b7F8A4d91A2da9AB4AF05d0FBe; + address constant TEST_SIGNER2 = 0x4ba0C2db9A26208b3bB1a50B01b16941c10D76db; + uint16 constant TEST_GOVERNANCE_CHAIN_ID = 1; + bytes32 constant TEST_GOVERNANCE_EMITTER = + 0x0000000000000000000000000000000000000000000000000000000000000011; + uint16 constant TEST_PYTH2_WORMHOLE_CHAIN_ID = 1; + bytes32 constant TEST_PYTH2_WORMHOLE_EMITTER = + 0x71f8dcb863d176e2c420ad6610cf687359612b6fb392e0642b0ca6b1f186aa3b; + uint16 constant TARGET_CHAIN_ID = 2; + + function setUp() public { + pyth = IPyth(setUpPyth(setUpWormholeReceiver(1))); + } + + function testNoOwner() public { + // Check that the ownership is renounced + assertEq(OwnableUpgradeable(address(pyth)).owner(), address(0)); + } + + function testValidDataSources() public { + assertTrue( + PythGetters(address(pyth)).isValidDataSource( + TEST_PYTH2_WORMHOLE_CHAIN_ID, + TEST_PYTH2_WORMHOLE_EMITTER + ) + ); + } + + function testSetFee() public { + // Set fee to 5000 (5000 = 5 * 10^3) + bytes memory setFeeMessage = abi.encodePacked( + MAGIC, + uint8(GovernanceModule.Target), + uint8(GovernanceAction.SetFee), + TARGET_CHAIN_ID, + uint64(5), // value + uint64(3) // exponent + ); + + bytes memory vaa = encodeAndSignMessage( + setFeeMessage, + TEST_GOVERNANCE_CHAIN_ID, + TEST_GOVERNANCE_EMITTER, + 1 + ); + + uint oldFee = PythGetters(address(pyth)).singleUpdateFeeInWei(); + vm.expectEmit(true, true, true, true); + emit FeeSet(oldFee, 5000); + + PythGovernance(address(pyth)).executeGovernanceInstruction(vaa); + assertEq(PythGetters(address(pyth)).singleUpdateFeeInWei(), 5000); + } + + function testSetValidPeriod() public { + // Create governance VAA to set valid period to 0 + bytes memory data = abi.encodePacked( + MAGIC, + uint8(GovernanceModule.Target), + uint8(GovernanceAction.SetValidPeriod), + TARGET_CHAIN_ID, // Target chain ID + uint64(0) // New valid period + ); + + bytes memory vaa = encodeAndSignMessage( + data, + TEST_GOVERNANCE_CHAIN_ID, + TEST_GOVERNANCE_EMITTER, + 1 + ); + + uint oldValidPeriod = PythGetters(address(pyth)) + .validTimePeriodSeconds(); + vm.expectEmit(true, true, true, true); + emit ValidPeriodSet(oldValidPeriod, 0); + + PythGovernance(address(pyth)).executeGovernanceInstruction(vaa); + assertEq(PythGetters(address(pyth)).validTimePeriodSeconds(), 0); + } + + function testInvalidGovernanceMessage() public { + // Test with wrong magic number + bytes memory data = abi.encodePacked( + bytes4(0x12345678), // Wrong magic + uint8(GovernanceModule.Target), + uint8(GovernanceAction.SetValidPeriod), + uint16(1), // Target chain ID + uint64(0) + ); + + bytes memory vaa = encodeAndSignMessage( + data, + TEST_GOVERNANCE_CHAIN_ID, + TEST_GOVERNANCE_EMITTER, + 1 + ); + + vm.expectRevert(PythErrors.InvalidGovernanceMessage.selector); + PythGovernance(address(pyth)).executeGovernanceInstruction(vaa); + } + + function testInvalidGovernanceTarget() public { + // Test with wrong chain target + bytes memory data = abi.encodePacked( + MAGIC, + uint8(GovernanceModule.Target), + uint8(GovernanceAction.SetValidPeriod), + uint16(3), // Different chain ID for testing invalid target + uint64(0) + ); + + bytes memory vaa = encodeAndSignMessage( + data, + TEST_GOVERNANCE_CHAIN_ID, + TEST_GOVERNANCE_EMITTER, + 1 + ); + + vm.expectRevert(PythErrors.InvalidGovernanceTarget.selector); + PythGovernance(address(pyth)).executeGovernanceInstruction(vaa); + } + + function testInvalidGovernanceDataSource() public { + // Test with wrong emitter + bytes memory data = abi.encodePacked( + MAGIC, + uint8(GovernanceModule.Target), + uint8(GovernanceAction.SetValidPeriod), + TARGET_CHAIN_ID, + uint64(0) + ); + + bytes memory vaa = encodeAndSignMessage( + data, + TEST_GOVERNANCE_CHAIN_ID, + bytes32(uint256(0x1111)), // Wrong emitter + 1 + ); + + vm.expectRevert(PythErrors.InvalidGovernanceDataSource.selector); + PythGovernance(address(pyth)).executeGovernanceInstruction(vaa); + } + + function testSetDataSources() public { + // Create governance VAA to set new data sources + bytes memory data = abi.encodePacked( + MAGIC, + uint8(GovernanceModule.Target), + uint8(GovernanceAction.SetDataSources), + TARGET_CHAIN_ID, // Target chain ID + uint8(1), // Number of data sources + uint16(1), // Chain ID + bytes32( + 0x0000000000000000000000000000000000000000000000000000000000001111 + ) // Emitter + ); + + bytes memory vaa = encodeAndSignMessage( + data, + TEST_GOVERNANCE_CHAIN_ID, + TEST_GOVERNANCE_EMITTER, + 1 + ); + + PythInternalStructs.DataSource[] memory oldDataSources = PythGetters( + address(pyth) + ).validDataSources(); + + PythInternalStructs.DataSource[] + memory newDataSources = new PythInternalStructs.DataSource[](1); + newDataSources[0] = PythInternalStructs.DataSource( + 1, + 0x0000000000000000000000000000000000000000000000000000000000001111 + ); + + vm.expectEmit(true, true, true, true); + emit DataSourcesSet(oldDataSources, newDataSources); + + PythGovernance(address(pyth)).executeGovernanceInstruction(vaa); + + // Verify old data source is no longer valid + assertFalse( + PythGetters(address(pyth)).isValidDataSource( + TEST_PYTH2_WORMHOLE_CHAIN_ID, + TEST_PYTH2_WORMHOLE_EMITTER + ) + ); + + // Verify new data source is valid + assertTrue( + PythGetters(address(pyth)).isValidDataSource( + 1, + 0x0000000000000000000000000000000000000000000000000000000000001111 + ) + ); + } + + function testSetWormholeAddress() public { + // Deploy a new wormhole contract + address newWormhole = address(setUpWormholeReceiver(1)); + + // Create governance VAA to set new wormhole address + bytes memory data = abi.encodePacked( + MAGIC, + uint8(GovernanceModule.Target), + uint8(GovernanceAction.SetWormholeAddress), + TARGET_CHAIN_ID, // Target chain ID + newWormhole // New wormhole address + ); + + bytes memory vaa = encodeAndSignMessage( + data, + TEST_GOVERNANCE_CHAIN_ID, + TEST_GOVERNANCE_EMITTER, + 1 + ); + + address oldWormhole = address(PythGetters(address(pyth)).wormhole()); + vm.expectEmit(true, true, true, true); + emit WormholeAddressSet(oldWormhole, newWormhole); + + PythGovernance(address(pyth)).executeGovernanceInstruction(vaa); + assertEq(address(PythGetters(address(pyth)).wormhole()), newWormhole); + } + + function testTransferGovernanceDataSource() public { + uint16 newEmitterChain = 2; + bytes32 newEmitterAddress = 0x0000000000000000000000000000000000000000000000000000000000001111; + + // Create claim VAA from new governance + bytes memory claimData = abi.encodePacked( + MAGIC, + uint8(GovernanceModule.Target), + uint8(GovernanceAction.RequestGovernanceDataSourceTransfer), + TARGET_CHAIN_ID, // Target chain ID + uint32(1) // New governance index + ); + + bytes memory claimVaa = encodeAndSignMessage( + claimData, + newEmitterChain, + newEmitterAddress, + 1 + ); + + // Create authorize VAA from current governance + bytes memory authData = abi.encodePacked( + MAGIC, + uint8(GovernanceModule.Target), + uint8(GovernanceAction.AuthorizeGovernanceDataSourceTransfer), + TARGET_CHAIN_ID, // Target chain ID + claimVaa + ); + + bytes memory authVaa = encodeAndSignMessage( + authData, + TEST_GOVERNANCE_CHAIN_ID, + TEST_GOVERNANCE_EMITTER, + 1 + ); + + PythInternalStructs.DataSource + memory oldDataSource = PythInternalStructs.DataSource( + TEST_GOVERNANCE_CHAIN_ID, + TEST_GOVERNANCE_EMITTER + ); + PythInternalStructs.DataSource + memory newDataSource = PythInternalStructs.DataSource( + newEmitterChain, + newEmitterAddress + ); + + vm.expectEmit(true, true, true, true); + emit GovernanceDataSourceSet(oldDataSource, newDataSource, 1); + + PythGovernance(address(pyth)).executeGovernanceInstruction(authVaa); + + // Verify old governance can't execute instructions anymore + bytes memory invalidData = abi.encodePacked( + MAGIC, + uint8(GovernanceModule.Target), + uint8(GovernanceAction.SetValidPeriod), + uint16(1), // Wrong chain ID for testing invalid target + uint64(0) + ); + + bytes memory invalidVaa = encodeAndSignMessage( + invalidData, + TEST_GOVERNANCE_CHAIN_ID, + TEST_GOVERNANCE_EMITTER, + 2 + ); + + vm.expectRevert(PythErrors.InvalidGovernanceDataSource.selector); + PythGovernance(address(pyth)).executeGovernanceInstruction(invalidVaa); + } + + function testSequentialGovernanceMessages() public { + // First governance message + bytes memory data1 = abi.encodePacked( + MAGIC, + uint8(GovernanceModule.Target), + uint8(GovernanceAction.SetValidPeriod), + TARGET_CHAIN_ID, // Target chain ID + uint64(10) + ); + + bytes memory vaa1 = encodeAndSignMessage( + data1, + TEST_GOVERNANCE_CHAIN_ID, + TEST_GOVERNANCE_EMITTER, + 1 + ); + + PythGovernance(address(pyth)).executeGovernanceInstruction(vaa1); + + // Second governance message + bytes memory data2 = abi.encodePacked( + MAGIC, + uint8(GovernanceModule.Target), + uint8(GovernanceAction.SetValidPeriod), + TARGET_CHAIN_ID, // Target chain ID + uint64(20) + ); + + bytes memory vaa2 = encodeAndSignMessage( + data2, + TEST_GOVERNANCE_CHAIN_ID, + TEST_GOVERNANCE_EMITTER, + 2 + ); + + PythGovernance(address(pyth)).executeGovernanceInstruction(vaa2); + + // Try to replay first message + vm.expectRevert(PythErrors.OldGovernanceMessage.selector); + PythGovernance(address(pyth)).executeGovernanceInstruction(vaa1); + + // Try to replay second message + vm.expectRevert(PythErrors.OldGovernanceMessage.selector); + PythGovernance(address(pyth)).executeGovernanceInstruction(vaa2); + } + + function testUpgradeContractWithChainIdZeroIsInvalid() public { + // Deploy a new PythUpgradable contract + PythUpgradable newImplementation = new PythUpgradable(); + + // Create governance VAA with chain ID 0 (unset) + bytes memory data = abi.encodePacked( + MAGIC, + uint8(GovernanceModule.Target), + uint8(GovernanceAction.UpgradeContract), + uint16(0), // Chain ID 0 (unset) + address(newImplementation) // New implementation address + ); + + bytes memory vaa = encodeAndSignMessage( + data, + TEST_GOVERNANCE_CHAIN_ID, + TEST_GOVERNANCE_EMITTER, + 1 + ); + + // Should revert with InvalidGovernanceTarget + vm.expectRevert(PythErrors.InvalidGovernanceTarget.selector); + PythGovernance(address(pyth)).executeGovernanceInstruction(vaa); + } + + // Helper function to get the second address from event data + function getSecondAddressFromEventData( + bytes memory data + ) internal pure returns (address) { + (, address secondAddr) = abi.decode(data, (address, address)); + return secondAddr; + } + + function testUpgradeContractShouldWork() public { + // Deploy a new PythUpgradable contract + PythUpgradable newImplementation = new PythUpgradable(); + + // Create governance VAA to upgrade the contract + bytes memory data = abi.encodePacked( + MAGIC, + uint8(GovernanceModule.Target), + uint8(GovernanceAction.UpgradeContract), + TARGET_CHAIN_ID, // Valid target chain ID + address(newImplementation) // New implementation address + ); + + bytes memory vaa = encodeAndSignMessage( + data, + TEST_GOVERNANCE_CHAIN_ID, + TEST_GOVERNANCE_EMITTER, + 1 + ); + + // Create a custom event checker for ContractUpgraded event + // Since we only care about the newImplementation parameter + vm.recordLogs(); + + // Execute the governance instruction + PythGovernance(address(pyth)).executeGovernanceInstruction(vaa); + + // Get emitted logs and check the event parameters + Vm.Log[] memory entries = vm.getRecordedLogs(); + bool foundUpgradeEvent = false; + + for (uint i = 0; i < entries.length; i++) { + // The event signature for ContractUpgraded + bytes32 eventSignature = keccak256( + "ContractUpgraded(address,address)" + ); + + if (entries[i].topics[0] == eventSignature) { + // This is a ContractUpgraded event + // Get just the new implementation address using our helper + address recordedNewImplementation = getSecondAddressFromEventData( + entries[i].data + ); + + // Check newImplementation + assertEq(recordedNewImplementation, address(newImplementation)); + foundUpgradeEvent = true; + break; + } + } + + // Make sure we found the event + assertTrue(foundUpgradeEvent, "ContractUpgraded event not found"); + + // Verify the upgrade worked by checking the magic number + assertEq( + PythUpgradable(address(pyth)).pythUpgradableMagic(), + 0x97a6f304 + ); + + // Verify the implementation was upgraded to our new implementation + // Access implementation using the ERC1967 storage slot + address implAddr = address( + uint160( + uint256( + vm.load( + address(pyth), + 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc // ERC1967 implementation slot + ) + ) + ) + ); + assertEq(implAddr, address(newImplementation)); + } + + function testUpgradeContractToNonPythContractWontWork() public { + // Deploy a mock upgradeable proxy that isn't a proper Pyth implementation + MockUpgradeableProxy newImplementation = new MockUpgradeableProxy(); + + // Create governance VAA to upgrade to an invalid implementation + bytes memory data = abi.encodePacked( + MAGIC, + uint8(GovernanceModule.Target), + uint8(GovernanceAction.UpgradeContract), + TARGET_CHAIN_ID, // Valid target chain ID + address(newImplementation) // Invalid implementation address + ); + + bytes memory vaa = encodeAndSignMessage( + data, + TEST_GOVERNANCE_CHAIN_ID, + TEST_GOVERNANCE_EMITTER, + 1 + ); + + // Should revert with no specific error message because the mock implementation + // doesn't have the pythUpgradableMagic method + vm.expectRevert(); + PythGovernance(address(pyth)).executeGovernanceInstruction(vaa); + } + + function testSetTransactionFee() public { + // Set transaction fee to 1000 (1000 = 1 * 10^3) + bytes memory setTransactionFeeMessage = abi.encodePacked( + MAGIC, + uint8(GovernanceModule.Target), + uint8(GovernanceAction.SetTransactionFee), + TARGET_CHAIN_ID, + uint64(1), // value + uint64(3) // exponent + ); + + bytes memory vaa = encodeAndSignMessage( + setTransactionFeeMessage, + TEST_GOVERNANCE_CHAIN_ID, + TEST_GOVERNANCE_EMITTER, + 1 + ); + + uint oldFee = PythGetters(address(pyth)).transactionFeeInWei(); + vm.expectEmit(true, true, true, true); + emit TransactionFeeSet(oldFee, 1000); + + PythGovernance(address(pyth)).executeGovernanceInstruction(vaa); + assertEq(PythGetters(address(pyth)).transactionFeeInWei(), 1000); + + // Test that update fee includes transaction fee + bytes[] memory updateData = new bytes[](0); + assertEq(pyth.getUpdateFee(updateData), 1000); + + // Test that insufficient fee reverts + vm.expectRevert(PythErrors.InsufficientFee.selector); + pyth.updatePriceFeeds{value: 999}(updateData); + + // Test that sufficient fee works + pyth.updatePriceFeeds{value: 1000}(updateData); + } + + function encodeAndSignWormholeMessage( + bytes memory data, + uint16 emitterChainId, + bytes32 emitterAddress, + uint64 sequence, + uint8 numGuardians + ) internal view returns (bytes memory) { + return + generateVaa( + uint32(block.timestamp), + emitterChainId, + emitterAddress, + sequence, + data, + numGuardians + ); + } + + function encodeAndSignMessage( + bytes memory data, + uint16 emitterChainId, + bytes32 emitterAddress, + uint64 sequence + ) internal view returns (bytes memory) { + return + encodeAndSignWormholeMessage( + data, + emitterChainId, + emitterAddress, + sequence, + 1 // Number of guardians + ); + } + + // Events + event ContractUpgraded( + address oldImplementation, + address newImplementation + ); + event GovernanceDataSourceSet( + PythInternalStructs.DataSource oldDataSource, + PythInternalStructs.DataSource newDataSource, + uint64 initialSequence + ); + event DataSourcesSet( + PythInternalStructs.DataSource[] oldDataSources, + PythInternalStructs.DataSource[] newDataSources + ); + event FeeSet(uint oldFee, uint newFee); + event ValidPeriodSet(uint oldValidPeriod, uint newValidPeriod); + event WormholeAddressSet( + address oldWormholeAddress, + address newWormholeAddress + ); + event TransactionFeeSet(uint oldFee, uint newFee); +} diff --git a/target_chains/ethereum/contracts/package.json b/target_chains/ethereum/contracts/package.json index 14243cbfc0..6f6ef476c4 100644 --- a/target_chains/ethereum/contracts/package.json +++ b/target_chains/ethereum/contracts/package.json @@ -1,6 +1,6 @@ { "name": "@pythnetwork/pyth-evm-contract", - "version": "1.4.3", + "version": "1.4.4-alpha.1", "description": "", "private": "true", "devDependencies": {