Skip to content

Commit 5ed1e16

Browse files
authored
Merge pull request #2573 from pyth-network/withdraw-fees
feat(target_chains/ethereum): add WithdrawFee action and implement related functionality in governance payload
2 parents d359f67 + fe8db0c commit 5ed1e16

File tree

9 files changed

+213
-3
lines changed

9 files changed

+213
-3
lines changed

governance/xc_admin/packages/xc_admin_common/src/__tests__/GovernancePayload.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
SetDataSources,
3434
} from "../governance_payload/SetDataSources";
3535
import { SetTransactionFee } from "../governance_payload/SetTransactionFee";
36+
import { WithdrawFee } from "../governance_payload/WithdrawFee";
3637

3738
test("GovernancePayload ser/de", (done) => {
3839
jest.setTimeout(60000);
@@ -431,6 +432,21 @@ function governanceActionArb(): Arbitrary<PythGovernanceAction> {
431432
.map(({ v, e }) => {
432433
return new SetTransactionFee(header.targetChainId, v, e);
433434
});
435+
} else if (header.action === "WithdrawFee") {
436+
return fc
437+
.record({
438+
targetAddress: hexBytesArb({ minLength: 20, maxLength: 20 }),
439+
value: fc.bigUintN(64),
440+
expo: fc.bigUintN(64),
441+
})
442+
.map(({ targetAddress, value, expo }) => {
443+
return new WithdrawFee(
444+
header.targetChainId,
445+
Buffer.from(targetAddress, "hex"),
446+
value,
447+
expo,
448+
);
449+
});
434450
} else {
435451
throw new Error("Unsupported action type");
436452
}

governance/xc_admin/packages/xc_admin_common/src/governance_payload/PythGovernanceAction.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export const TargetAction = {
1717
SetWormholeAddress: 6,
1818
SetFeeInToken: 7,
1919
SetTransactionFee: 8,
20+
WithdrawFee: 9,
2021
} as const;
2122

2223
export const EvmExecutorAction = {
@@ -49,6 +50,8 @@ export function toActionName(
4950
return "SetFeeInToken";
5051
case 8:
5152
return "SetTransactionFee";
53+
case 9:
54+
return "WithdrawFee";
5255
}
5356
} else if (
5457
deserialized.moduleId == MODULE_EVM_EXECUTOR &&
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import {
2+
PythGovernanceActionImpl,
3+
PythGovernanceHeader,
4+
} from "./PythGovernanceAction";
5+
import * as BufferLayout from "@solana/buffer-layout";
6+
import * as BufferLayoutExt from "./BufferLayoutExt";
7+
import { ChainName } from "../chains";
8+
9+
/** Withdraw fees from the target chain to the specified address */
10+
export class WithdrawFee extends PythGovernanceActionImpl {
11+
static layout: BufferLayout.Structure<
12+
Readonly<{ targetAddress: string; value: bigint; expo: bigint }>
13+
> = BufferLayout.struct([
14+
BufferLayoutExt.hexBytes(20, "targetAddress"), // Ethereum address as hex string
15+
BufferLayoutExt.u64be("value"), // uint64 for value
16+
BufferLayoutExt.u64be("expo"), // uint64 for exponent
17+
]);
18+
19+
constructor(
20+
targetChainId: ChainName,
21+
readonly targetAddress: Buffer,
22+
readonly value: bigint,
23+
readonly expo: bigint,
24+
) {
25+
super(targetChainId, "WithdrawFee");
26+
}
27+
28+
static decode(data: Buffer): WithdrawFee | undefined {
29+
const decoded = PythGovernanceActionImpl.decodeWithPayload(
30+
data,
31+
"WithdrawFee",
32+
WithdrawFee.layout,
33+
);
34+
if (!decoded) return undefined;
35+
36+
return new WithdrawFee(
37+
decoded[0].targetChainId,
38+
Buffer.from(decoded[1].targetAddress, "hex"),
39+
decoded[1].value,
40+
decoded[1].expo,
41+
);
42+
}
43+
44+
encode(): Buffer {
45+
return super.encodeWithPayload(WithdrawFee.layout, {
46+
targetAddress: this.targetAddress.toString("hex"),
47+
value: this.value,
48+
expo: this.expo,
49+
});
50+
}
51+
}

governance/xc_admin/packages/xc_admin_common/src/governance_payload/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
} from "./SetWormholeAddress";
2222
import { EvmExecute } from "./ExecuteAction";
2323
import { SetTransactionFee } from "./SetTransactionFee";
24+
import { WithdrawFee } from "./WithdrawFee";
2425

2526
/** Decode a governance payload */
2627
export function decodeGovernancePayload(
@@ -76,6 +77,8 @@ export function decodeGovernancePayload(
7677
return EvmExecute.decode(data);
7778
case "SetTransactionFee":
7879
return SetTransactionFee.decode(data);
80+
case "WithdrawFee":
81+
return WithdrawFee.decode(data);
7982
default:
8083
return undefined;
8184
}
@@ -92,3 +95,4 @@ export * from "./SetFee";
9295
export * from "./SetTransactionFee";
9396
export * from "./SetWormholeAddress";
9497
export * from "./ExecuteAction";
98+
export * from "./WithdrawFee";

target_chains/ethereum/contracts/contracts/pyth/Pyth.sol

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -555,7 +555,7 @@ abstract contract Pyth is
555555
}
556556

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

561561
function calculateTwap(

target_chains/ethereum/contracts/contracts/pyth/PythGovernance.sol

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ abstract contract PythGovernance is
3939
address newWormholeAddress
4040
);
4141
event TransactionFeeSet(uint oldFee, uint newFee);
42+
event FeeWithdrawn(address targetAddress, uint fee);
4243

4344
function verifyGovernanceVM(
4445
bytes memory encodedVM
@@ -100,6 +101,8 @@ abstract contract PythGovernance is
100101
);
101102
} else if (gi.action == GovernanceAction.SetTransactionFee) {
102103
setTransactionFee(parseSetTransactionFeePayload(gi.payload));
104+
} else if (gi.action == GovernanceAction.WithdrawFee) {
105+
withdrawFee(parseWithdrawFeePayload(gi.payload));
103106
} else {
104107
revert PythErrors.InvalidGovernanceMessage();
105108
}
@@ -255,4 +258,14 @@ abstract contract PythGovernance is
255258

256259
emit TransactionFeeSet(oldFee, transactionFeeInWei());
257260
}
261+
262+
function withdrawFee(WithdrawFeePayload memory payload) internal {
263+
if (payload.fee > address(this).balance)
264+
revert PythErrors.InsufficientFee();
265+
266+
(bool success, ) = payload.targetAddress.call{value: payload.fee}("");
267+
require(success, "Failed to withdraw fees");
268+
269+
emit FeeWithdrawn(payload.targetAddress, payload.fee);
270+
}
258271
}

target_chains/ethereum/contracts/contracts/pyth/PythGovernanceInstructions.sol

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ contract PythGovernanceInstructions {
3535
SetValidPeriod, // 4
3636
RequestGovernanceDataSourceTransfer, // 5
3737
SetWormholeAddress, // 6
38-
SetTransactionFee // 7
38+
SetTransactionFee, // 7
39+
WithdrawFee // 8
3940
}
4041

4142
struct GovernanceInstruction {
@@ -82,6 +83,12 @@ contract PythGovernanceInstructions {
8283
uint newFee;
8384
}
8485

86+
struct WithdrawFeePayload {
87+
address targetAddress;
88+
// Fee in wei, matching the native uint256 type used for address.balance in EVM
89+
uint256 fee;
90+
}
91+
8592
/// @dev Parse a GovernanceInstruction
8693
function parseGovernanceInstruction(
8794
bytes memory encodedInstruction
@@ -243,4 +250,25 @@ contract PythGovernanceInstructions {
243250
if (encodedPayload.length != index)
244251
revert PythErrors.InvalidGovernanceMessage();
245252
}
253+
254+
/// @dev Parse a WithdrawFeePayload (action 8) with minimal validation
255+
function parseWithdrawFeePayload(
256+
bytes memory encodedPayload
257+
) public pure returns (WithdrawFeePayload memory wf) {
258+
uint index = 0;
259+
260+
wf.targetAddress = address(encodedPayload.toAddress(index));
261+
index += 20;
262+
263+
uint64 val = encodedPayload.toUint64(index);
264+
index += 8;
265+
266+
uint64 expo = encodedPayload.toUint64(index);
267+
index += 8;
268+
269+
wf.fee = uint256(val) * uint256(10) ** uint256(expo);
270+
271+
if (encodedPayload.length != index)
272+
revert PythErrors.InvalidGovernanceMessage();
273+
}
246274
}

target_chains/ethereum/contracts/forge-test/PythGovernance.t.sol

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -556,6 +556,100 @@ contract PythGovernanceTest is
556556
pyth.updatePriceFeeds{value: 1000}(updateData);
557557
}
558558

559+
function testWithdrawFee() public {
560+
// First send some ETH to the contract
561+
bytes[] memory updateData = new bytes[](0);
562+
pyth.updatePriceFeeds{value: 1 ether}(updateData);
563+
assertEq(address(pyth).balance, 1 ether);
564+
565+
address recipient = makeAddr("recipient");
566+
567+
// Create governance VAA to withdraw fee
568+
bytes memory withdrawMessage = abi.encodePacked(
569+
MAGIC,
570+
uint8(GovernanceModule.Target),
571+
uint8(GovernanceAction.WithdrawFee),
572+
TARGET_CHAIN_ID,
573+
recipient,
574+
uint64(5), // value = 5
575+
uint64(17) // exponent = 17 (5 * 10^17 = 0.5 ether)
576+
);
577+
578+
bytes memory vaa = encodeAndSignMessage(
579+
withdrawMessage,
580+
TEST_GOVERNANCE_CHAIN_ID,
581+
TEST_GOVERNANCE_EMITTER,
582+
1
583+
);
584+
585+
vm.expectEmit(true, true, true, true);
586+
emit FeeWithdrawn(recipient, 0.5 ether);
587+
588+
PythGovernance(address(pyth)).executeGovernanceInstruction(vaa);
589+
590+
assertEq(address(pyth).balance, 0.5 ether);
591+
assertEq(recipient.balance, 0.5 ether);
592+
}
593+
594+
function testWithdrawFeeInsufficientBalance() public {
595+
// First send some ETH to the contract
596+
bytes[] memory updateData = new bytes[](0);
597+
pyth.updatePriceFeeds{value: 1 ether}(updateData);
598+
assertEq(address(pyth).balance, 1 ether);
599+
600+
address recipient = makeAddr("recipient");
601+
602+
// Create governance VAA to withdraw fee
603+
bytes memory withdrawMessage = abi.encodePacked(
604+
MAGIC,
605+
uint8(GovernanceModule.Target),
606+
uint8(GovernanceAction.WithdrawFee),
607+
TARGET_CHAIN_ID,
608+
recipient,
609+
uint64(2), // value = 2
610+
uint64(18) // exponent = 18 (2 * 10^18 = 2 ether, more than balance)
611+
);
612+
613+
bytes memory vaa = encodeAndSignMessage(
614+
withdrawMessage,
615+
TEST_GOVERNANCE_CHAIN_ID,
616+
TEST_GOVERNANCE_EMITTER,
617+
1
618+
);
619+
620+
vm.expectRevert(PythErrors.InsufficientFee.selector);
621+
PythGovernance(address(pyth)).executeGovernanceInstruction(vaa);
622+
623+
// Balances should remain unchanged
624+
assertEq(address(pyth).balance, 1 ether);
625+
assertEq(recipient.balance, 0);
626+
}
627+
628+
function testWithdrawFeeInvalidGovernance() public {
629+
address recipient = makeAddr("recipient");
630+
631+
// Create governance VAA with wrong emitter
632+
bytes memory withdrawMessage = abi.encodePacked(
633+
MAGIC,
634+
uint8(GovernanceModule.Target),
635+
uint8(GovernanceAction.WithdrawFee),
636+
TARGET_CHAIN_ID,
637+
recipient,
638+
uint64(5), // value = 5
639+
uint64(17) // exponent = 17 (5 * 10^17 = 0.5 ether)
640+
);
641+
642+
bytes memory vaa = encodeAndSignMessage(
643+
withdrawMessage,
644+
TEST_GOVERNANCE_CHAIN_ID,
645+
bytes32(uint256(0x1111)), // Wrong emitter
646+
1
647+
);
648+
649+
vm.expectRevert(PythErrors.InvalidGovernanceDataSource.selector);
650+
PythGovernance(address(pyth)).executeGovernanceInstruction(vaa);
651+
}
652+
559653
function encodeAndSignWormholeMessage(
560654
bytes memory data,
561655
uint16 emitterChainId,
@@ -611,4 +705,5 @@ contract PythGovernanceTest is
611705
address newWormholeAddress
612706
);
613707
event TransactionFeeSet(uint oldFee, uint newFee);
708+
event FeeWithdrawn(address recipient, uint256 fee);
614709
}

target_chains/ethereum/contracts/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@pythnetwork/pyth-evm-contract",
3-
"version": "1.4.4-alpha.2",
3+
"version": "1.4.4-alpha.5",
44
"description": "",
55
"private": "true",
66
"devDependencies": {

0 commit comments

Comments
 (0)