Skip to content

feat(target_chains/ethereum): add WithdrawFee action and implement related functionality in governance payload #2573

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Apr 17, 2025
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
SetDataSources,
} from "../governance_payload/SetDataSources";
import { SetTransactionFee } from "../governance_payload/SetTransactionFee";
import { WithdrawFee } from "../governance_payload/WithdrawFee";

test("GovernancePayload ser/de", (done) => {
jest.setTimeout(60000);
Expand Down Expand Up @@ -431,6 +432,21 @@ function governanceActionArb(): Arbitrary<PythGovernanceAction> {
.map(({ v, e }) => {
return new SetTransactionFee(header.targetChainId, v, e);
});
} else if (header.action === "WithdrawFee") {
return fc
.record({
targetAddress: hexBytesArb({ minLength: 20, maxLength: 20 }),
value: fc.bigUintN(64),
expo: fc.bigUintN(64),
})
.map(({ targetAddress, value, expo }) => {
return new WithdrawFee(
header.targetChainId,
Buffer.from(targetAddress, "hex"),
value,
expo,
);
});
} else {
throw new Error("Unsupported action type");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const TargetAction = {
SetWormholeAddress: 6,
SetFeeInToken: 7,
SetTransactionFee: 8,
WithdrawFee: 9,
} as const;

export const EvmExecutorAction = {
Expand Down Expand Up @@ -49,6 +50,8 @@ export function toActionName(
return "SetFeeInToken";
case 8:
return "SetTransactionFee";
case 9:
return "WithdrawFee";
}
} else if (
deserialized.moduleId == MODULE_EVM_EXECUTOR &&
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import {
PythGovernanceActionImpl,
PythGovernanceHeader,
} from "./PythGovernanceAction";
import * as BufferLayout from "@solana/buffer-layout";
import * as BufferLayoutExt from "./BufferLayoutExt";
import { ChainName } from "../chains";

/** Withdraw fees from the target chain to the specified address */
export class WithdrawFee extends PythGovernanceActionImpl {
static layout: BufferLayout.Structure<
Readonly<{ targetAddress: string; value: bigint; expo: bigint }>
> = BufferLayout.struct([
BufferLayoutExt.hexBytes(20, "targetAddress"), // Ethereum address as hex string
BufferLayoutExt.u64be("value"), // uint64 for value
BufferLayoutExt.u64be("expo"), // uint64 for exponent
]);

constructor(
targetChainId: ChainName,
readonly targetAddress: Buffer,
readonly value: bigint,
readonly expo: bigint,
) {
super(targetChainId, "WithdrawFee");
}

static decode(data: Buffer): WithdrawFee | undefined {
const decoded = PythGovernanceActionImpl.decodeWithPayload(
data,
"WithdrawFee",
WithdrawFee.layout,
);
if (!decoded) return undefined;

return new WithdrawFee(
decoded[0].targetChainId,
Buffer.from(decoded[1].targetAddress, "hex"),
decoded[1].value,
decoded[1].expo,
);
}

encode(): Buffer {
return super.encodeWithPayload(WithdrawFee.layout, {
targetAddress: this.targetAddress.toString("hex"),
value: this.value,
expo: this.expo,
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
} from "./SetWormholeAddress";
import { EvmExecute } from "./ExecuteAction";
import { SetTransactionFee } from "./SetTransactionFee";
import { WithdrawFee } from "./WithdrawFee";

/** Decode a governance payload */
export function decodeGovernancePayload(
Expand Down Expand Up @@ -76,6 +77,8 @@ export function decodeGovernancePayload(
return EvmExecute.decode(data);
case "SetTransactionFee":
return SetTransactionFee.decode(data);
case "WithdrawFee":
return WithdrawFee.decode(data);
default:
return undefined;
}
Expand All @@ -92,3 +95,4 @@ export * from "./SetFee";
export * from "./SetTransactionFee";
export * from "./SetWormholeAddress";
export * from "./ExecuteAction";
export * from "./WithdrawFee";
2 changes: 1 addition & 1 deletion target_chains/ethereum/contracts/contracts/pyth/Pyth.sol
Original file line number Diff line number Diff line change
Expand Up @@ -555,7 +555,7 @@ abstract contract Pyth is
}

function version() public pure returns (string memory) {
return "1.4.4-alpha.4";
return "1.4.4-alpha.5";
}

function calculateTwap(
Expand Down
13 changes: 13 additions & 0 deletions target_chains/ethereum/contracts/contracts/pyth/PythGovernance.sol
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ abstract contract PythGovernance is
address newWormholeAddress
);
event TransactionFeeSet(uint oldFee, uint newFee);
event FeeWithdrawn(address targetAddress, uint fee);

function verifyGovernanceVM(
bytes memory encodedVM
Expand Down Expand Up @@ -100,6 +101,8 @@ abstract contract PythGovernance is
);
} else if (gi.action == GovernanceAction.SetTransactionFee) {
setTransactionFee(parseSetTransactionFeePayload(gi.payload));
} else if (gi.action == GovernanceAction.WithdrawFee) {
withdrawFee(parseWithdrawFeePayload(gi.payload));
} else {
revert PythErrors.InvalidGovernanceMessage();
}
Expand Down Expand Up @@ -255,4 +258,14 @@ abstract contract PythGovernance is

emit TransactionFeeSet(oldFee, transactionFeeInWei());
}

function withdrawFee(WithdrawFeePayload memory payload) internal {
if (payload.fee > address(this).balance)
revert PythErrors.InsufficientFee();

(bool success, ) = payload.targetAddress.call{value: payload.fee}("");
require(success, "Failed to withdraw fees");

emit FeeWithdrawn(payload.targetAddress, payload.fee);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ contract PythGovernanceInstructions {
SetValidPeriod, // 4
RequestGovernanceDataSourceTransfer, // 5
SetWormholeAddress, // 6
SetTransactionFee // 7
SetTransactionFee, // 7
WithdrawFee // 8
}

struct GovernanceInstruction {
Expand Down Expand Up @@ -82,6 +83,12 @@ contract PythGovernanceInstructions {
uint newFee;
}

struct WithdrawFeePayload {
address targetAddress;
// Fee in wei, matching the native uint256 type used for address.balance in EVM
uint256 fee;
}

/// @dev Parse a GovernanceInstruction
function parseGovernanceInstruction(
bytes memory encodedInstruction
Expand Down Expand Up @@ -243,4 +250,25 @@ contract PythGovernanceInstructions {
if (encodedPayload.length != index)
revert PythErrors.InvalidGovernanceMessage();
}

/// @dev Parse a WithdrawFeePayload (action 8) with minimal validation
function parseWithdrawFeePayload(
bytes memory encodedPayload
) public pure returns (WithdrawFeePayload memory wf) {
uint index = 0;

wf.targetAddress = address(encodedPayload.toAddress(index));
index += 20;

uint64 val = encodedPayload.toUint64(index);
index += 8;

uint64 expo = encodedPayload.toUint64(index);
index += 8;

wf.fee = uint256(val) * uint256(10) ** uint256(expo);

if (encodedPayload.length != index)
revert PythErrors.InvalidGovernanceMessage();
}
}
95 changes: 95 additions & 0 deletions target_chains/ethereum/contracts/forge-test/PythGovernance.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,100 @@ contract PythGovernanceTest is
pyth.updatePriceFeeds{value: 1000}(updateData);
}

function testWithdrawFee() public {
// First send some ETH to the contract
bytes[] memory updateData = new bytes[](0);
pyth.updatePriceFeeds{value: 1 ether}(updateData);
assertEq(address(pyth).balance, 1 ether);

address recipient = makeAddr("recipient");

// Create governance VAA to withdraw fee
bytes memory withdrawMessage = abi.encodePacked(
MAGIC,
uint8(GovernanceModule.Target),
uint8(GovernanceAction.WithdrawFee),
TARGET_CHAIN_ID,
recipient,
uint64(5), // value = 5
uint64(17) // exponent = 17 (5 * 10^17 = 0.5 ether)
);

bytes memory vaa = encodeAndSignMessage(
withdrawMessage,
TEST_GOVERNANCE_CHAIN_ID,
TEST_GOVERNANCE_EMITTER,
1
);

vm.expectEmit(true, true, true, true);
emit FeeWithdrawn(recipient, 0.5 ether);

PythGovernance(address(pyth)).executeGovernanceInstruction(vaa);

assertEq(address(pyth).balance, 0.5 ether);
assertEq(recipient.balance, 0.5 ether);
}

function testWithdrawFeeInsufficientBalance() public {
// First send some ETH to the contract
bytes[] memory updateData = new bytes[](0);
pyth.updatePriceFeeds{value: 1 ether}(updateData);
assertEq(address(pyth).balance, 1 ether);

address recipient = makeAddr("recipient");

// Create governance VAA to withdraw fee
bytes memory withdrawMessage = abi.encodePacked(
MAGIC,
uint8(GovernanceModule.Target),
uint8(GovernanceAction.WithdrawFee),
TARGET_CHAIN_ID,
recipient,
uint64(2), // value = 2
uint64(18) // exponent = 18 (2 * 10^18 = 2 ether, more than balance)
);

bytes memory vaa = encodeAndSignMessage(
withdrawMessage,
TEST_GOVERNANCE_CHAIN_ID,
TEST_GOVERNANCE_EMITTER,
1
);

vm.expectRevert(PythErrors.InsufficientFee.selector);
PythGovernance(address(pyth)).executeGovernanceInstruction(vaa);

// Balances should remain unchanged
assertEq(address(pyth).balance, 1 ether);
assertEq(recipient.balance, 0);
}

function testWithdrawFeeInvalidGovernance() public {
address recipient = makeAddr("recipient");

// Create governance VAA with wrong emitter
bytes memory withdrawMessage = abi.encodePacked(
MAGIC,
uint8(GovernanceModule.Target),
uint8(GovernanceAction.WithdrawFee),
TARGET_CHAIN_ID,
recipient,
uint64(5), // value = 5
uint64(17) // exponent = 17 (5 * 10^17 = 0.5 ether)
);

bytes memory vaa = encodeAndSignMessage(
withdrawMessage,
TEST_GOVERNANCE_CHAIN_ID,
bytes32(uint256(0x1111)), // Wrong emitter
1
);

vm.expectRevert(PythErrors.InvalidGovernanceDataSource.selector);
PythGovernance(address(pyth)).executeGovernanceInstruction(vaa);
}

function encodeAndSignWormholeMessage(
bytes memory data,
uint16 emitterChainId,
Expand Down Expand Up @@ -611,4 +705,5 @@ contract PythGovernanceTest is
address newWormholeAddress
);
event TransactionFeeSet(uint oldFee, uint newFee);
event FeeWithdrawn(address recipient, uint256 fee);
}
2 changes: 1 addition & 1 deletion target_chains/ethereum/contracts/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@pythnetwork/pyth-evm-contract",
"version": "1.4.4-alpha.2",
"version": "1.4.4-alpha.5",
"description": "",
"private": "true",
"devDependencies": {
Expand Down
Loading