diff --git a/contract_manager/scripts/entropy-accept-admin-and-ownership.ts b/contract_manager/scripts/entropy-accept-admin-and-ownership.ts new file mode 100644 index 00000000..8d1ba465 --- /dev/null +++ b/contract_manager/scripts/entropy-accept-admin-and-ownership.ts @@ -0,0 +1,86 @@ +import yargs from "yargs"; +import { hideBin } from "yargs/helpers"; +import { DefaultStore, EvmChain, loadHotWallet } from "../src"; + +const parser = yargs(hideBin(process.argv)) + .usage( + "Creates governance proposal to accept pending admin or ownership transfer for Pyth entropy contracts.\n" + + "Usage: $0 --chain --chain --ops-key-path " + ) + .options({ + testnet: { + type: "boolean", + default: false, + desc: "Accept for testnet contracts instead of mainnet", + }, + "all-chains": { + type: "boolean", + default: false, + desc: "Accept for contract on all chains. Use with --testnet flag to accept for all testnet contracts", + }, + chain: { + type: "array", + string: true, + desc: "Accept for contract on given chains", + }, + "ops-key-path": { + type: "string", + demandOption: true, + desc: "Path to the private key of the proposer to use for the operations multisig governance proposal", + }, + }); + +async function main() { + const argv = await parser.argv; + const selectedChains: EvmChain[] = []; + + if (argv.allChains && argv.chain) + throw new Error("Cannot use both --all-chains and --chain"); + if (!argv.allChains && !argv.chain) + throw new Error("Must use either --all-chains or --chain"); + for (const chain of Object.values(DefaultStore.chains)) { + if (!(chain instanceof EvmChain)) continue; + if ( + (argv.allChains && chain.isMainnet() !== argv.testnet) || + argv.chain?.includes(chain.getId()) + ) + selectedChains.push(chain); + } + if (argv.chain && selectedChains.length !== argv.chain.length) + throw new Error( + `Some chains were not found ${selectedChains + .map((chain) => chain.getId()) + .toString()}` + ); + for (const chain of selectedChains) { + if (chain.isMainnet() != selectedChains[0].isMainnet()) + throw new Error("All chains must be either mainnet or testnet"); + } + + const vault = + DefaultStore.vaults[ + "mainnet-beta_FVQyHcooAtThJ83XFrNnv74BcinbRH3bRmfFamAHBfuj" + ]; + + const payloads: Buffer[] = []; + for (const contract of Object.values(DefaultStore.entropy_contracts)) { + if (selectedChains.includes(contract.chain)) { + console.log("Creating payload for chain: ", contract.chain.getId()); + const pendingOwner = await contract.getPendingOwner(); + const adminPayload = contract.generateAcceptAdminPayload(pendingOwner); + const ownerPayload = + contract.generateAcceptOwnershipPayload(pendingOwner); + + payloads.push(adminPayload, ownerPayload); + } + } + + console.log("Using vault at for proposal", vault.getId()); + const wallet = await loadHotWallet(argv["ops-key-path"]); + console.log("Using wallet ", wallet.publicKey.toBase58()); + await vault.connect(wallet); + const proposal = await vault.proposeWormholeMessage(payloads); + console.log("Proposal address", proposal.address.toBase58()); +} + +main(); diff --git a/contract_manager/src/contracts/evm.ts b/contract_manager/src/contracts/evm.ts index 4013d4c7..7771c55a 100644 --- a/contract_manager/src/contracts/evm.ts +++ b/contract_manager/src/contracts/evm.ts @@ -3,11 +3,54 @@ import PythInterfaceAbi from "@pythnetwork/pyth-sdk-solidity/abis/IPyth.json"; import EntropyAbi from "@pythnetwork/entropy-sdk-solidity/abis/IEntropy.json"; import { PriceFeedContract, PrivateKey, Storable } from "../base"; import { Chain, EvmChain } from "../chains"; -import { DataSource } from "xc_admin_common"; +import { DataSource, EvmExecute } from "xc_admin_common"; import { WormholeContract } from "./wormhole"; // Just to make sure tx gas limit is enough const GAS_ESTIMATE_MULTIPLIER = 2; +const EXTENDED_ENTROPY_ABI = [ + { + inputs: [], + name: "acceptOwnership", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [], + name: "acceptAdmin", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [], + name: "owner", + outputs: [ + { + internalType: "address", + name: "", + type: "address", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "pendingOwner", + outputs: [ + { + internalType: "address", + name: "", + type: "address", + }, + ], + stateMutability: "view", + type: "function", + }, + ...EntropyAbi, +] as any; // eslint-disable-line @typescript-eslint/no-explicit-any const EXTENDED_PYTH_ABI = [ { inputs: [], @@ -321,6 +364,42 @@ export class EvmEntropyContract extends Storable { return new EvmEntropyContract(chain, parsed.address); } + // Generate a payload for the given executor address and calldata. + // `executor` and `calldata` should be hex strings. + generateExecutorPayload(executor: string, calldata: string) { + return new EvmExecute( + this.chain.wormholeChainName, + executor.replace("0x", ""), + this.address.replace("0x", ""), + 0n, + Buffer.from(calldata.replace("0x", ""), "hex") + ).encode(); + } + + // Generates a payload for the newAdmin to call acceptAdmin on the entropy contracts + generateAcceptAdminPayload(newAdmin: string): Buffer { + const contract = this.getContract(); + const data = contract.methods.acceptAdmin().encodeABI(); + return this.generateExecutorPayload(newAdmin, data); + } + + // Generates a payload for newOwner to call acceptOwnership on the entropy contracts + generateAcceptOwnershipPayload(newOwner: string): Buffer { + const contract = this.getContract(); + const data = contract.methods.acceptOwnership().encodeABI(); + return this.generateExecutorPayload(newOwner, data); + } + + getOwner(): string { + const contract = this.getContract(); + return contract.methods.owner().call(); + } + + getPendingOwner(): string { + const contract = this.getContract(); + return contract.methods.pendingOwner().call(); + } + toJson() { return { chain: this.chain.getId(), @@ -331,7 +410,7 @@ export class EvmEntropyContract extends Storable { getContract() { const web3 = new Web3(this.chain.getRpcUrl()); - return new web3.eth.Contract(EntropyAbi as any, this.address); // eslint-disable-line @typescript-eslint/no-explicit-any + return new web3.eth.Contract(EXTENDED_ENTROPY_ABI, this.address); } async getDefaultProvider(): Promise { diff --git a/contract_manager/store/chains/EvmChains.yaml b/contract_manager/store/chains/EvmChains.yaml index a0045fe9..a890cdab 100644 --- a/contract_manager/store/chains/EvmChains.yaml +++ b/contract_manager/store/chains/EvmChains.yaml @@ -428,3 +428,18 @@ rpcUrl: https://rpc.ankr.com/filecoin networkId: 314 type: EvmChain +- id: lightlink_pegasus_testnet + mainnet: false + rpcUrl: https://replicator.pegasus.lightlink.io/rpc/v1 + networkId: 1891 + type: EvmChain +- id: sei_evm_devnet + mainnet: false + rpcUrl: https://evm-devnet.seinetwork.io + networkId: 713715 + type: EvmChain +- id: fantom_sonic_testnet + mainnet: false + rpcUrl: https://rpc.sonic.fantom.network/ + networkId: 64165 + type: EvmChain diff --git a/contract_manager/store/contracts/EvmEntropyContracts.yaml b/contract_manager/store/contracts/EvmEntropyContracts.yaml index 26bc2877..d5b720fb 100644 --- a/contract_manager/store/contracts/EvmEntropyContracts.yaml +++ b/contract_manager/store/contracts/EvmEntropyContracts.yaml @@ -1,3 +1,24 @@ +- chain: lightlink_pegasus_testnet + address: "0x8250f4aF4B972684F7b336503E2D6dFeDeB1487a" + type: EvmEntropyContract +- chain: chiliz_spicy + address: "0xD458261E832415CFd3BAE5E416FdF3230ce6F134" + type: EvmEntropyContract +- chain: conflux_espace_testnet + address: "0xdF21D137Aadc95588205586636710ca2890538d5" + type: EvmEntropyContract +- chain: mode_testnet + address: "0x98046Bd286715D3B0BC227Dd7a956b83D8978603" + type: EvmEntropyContract +- chain: arbitrum_sepolia + address: "0x549Ebba8036Ab746611B4fFA1423eb0A4Df61440" + type: EvmEntropyContract - chain: base_goerli address: "0x4374e5a8b9C22271E9EB878A2AA31DE97DF15DAF" - type: EvmPriceFeedContract + type: EvmEntropyContract +- chain: fantom_sonic_testnet + address: "0xb27e5ca259702f209a29225d0eDdC131039C9933" + type: EvmEntropyContract +- chain: blast_s2_testnet + address: "0x98046Bd286715D3B0BC227Dd7a956b83D8978603" + type: EvmEntropyContract diff --git a/governance/xc_admin/packages/xc_admin_common/package.json b/governance/xc_admin/packages/xc_admin_common/package.json index 6b331186..db6aeccd 100644 --- a/governance/xc_admin/packages/xc_admin_common/package.json +++ b/governance/xc_admin/packages/xc_admin_common/package.json @@ -26,6 +26,7 @@ "@solana/buffer-layout": "^4.0.1", "@solana/web3.js": "^1.73.0", "@sqds/mesh": "^1.0.6", + "bigint-buffer": "^1.1.5", "ethers": "^5.7.2", "lodash": "^4.17.21", "typescript": "^4.9.4" @@ -34,9 +35,9 @@ "@types/bn.js": "^5.1.1", "@types/jest": "^29.2.5", "@types/lodash": "^4.14.191", + "fast-check": "^3.10.0", "jest": "^29.3.1", "prettier": "^2.8.1", - "ts-jest": "^29.0.3", - "fast-check": "^3.10.0" + "ts-jest": "^29.0.3" } } diff --git a/governance/xc_admin/packages/xc_admin_common/src/__tests__/BufferLayoutExt.test.ts b/governance/xc_admin/packages/xc_admin_common/src/__tests__/BufferLayoutExt.test.ts new file mode 100644 index 00000000..361957ee --- /dev/null +++ b/governance/xc_admin/packages/xc_admin_common/src/__tests__/BufferLayoutExt.test.ts @@ -0,0 +1,20 @@ +import fc from "fast-check"; +import { u64be } from "../governance_payload/BufferLayoutExt"; + +test("Buffer layout extension fc tests", (done) => { + const u64 = u64be(); + fc.assert( + fc.property(fc.bigUintN(64), (bi) => { + let encodedUint8Array = new Uint8Array(8); + u64.encode(bi, encodedUint8Array); + + let buffer = Buffer.alloc(8); + buffer.writeBigUInt64BE(bi); + + const decodedBI = u64.decode(buffer); + return buffer.equals(encodedUint8Array) && bi === decodedBI; + }) + ); + + done(); +}); 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 9eb2ad81..ac5a60e4 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 @@ -10,6 +10,8 @@ import { PythGovernanceAction, decodeGovernancePayload, EvmSetWormholeAddress, + EvmExecutorAction, + EvmExecute, } from ".."; import * as fc from "fast-check"; import { ChainName, CHAINS } from "../chains"; @@ -66,6 +68,14 @@ test("GovernancePayload ser/de", (done) => { expect(governanceHeader?.targetChainId).toBe("solana"); expect(governanceHeader?.action).toBe("SetFee"); + // Valid header 3 + expectedGovernanceHeader = new PythGovernanceHeader("solana", "Execute"); + buffer = expectedGovernanceHeader.encode(); + expect(buffer.equals(Buffer.from([80, 84, 71, 77, 2, 0, 0, 1]))).toBeTruthy(); + governanceHeader = PythGovernanceHeader.decode(buffer); + expect(governanceHeader?.targetChainId).toBe("solana"); + expect(governanceHeader?.action).toBe("Execute"); + // Wrong magic number expect( PythGovernanceHeader.decode( @@ -157,6 +167,7 @@ function governanceHeaderArb(): Arbitrary { const actions = [ ...Object.keys(ExecutorAction), ...Object.keys(TargetAction), + ...Object.keys(EvmExecutorAction), ] as ActionName[]; const actionArb = fc.constantFrom(...actions); const targetChainIdArb = fc.constantFrom( @@ -260,6 +271,24 @@ function governanceActionArb(): Arbitrary { return hexBytesArb({ minLength: 20, maxLength: 20 }).map((address) => { return new EvmSetWormholeAddress(header.targetChainId, address); }); + } else if (header.action === "Execute") { + return fc + .record({ + executerAddress: hexBytesArb({ minLength: 20, maxLength: 20 }), + callAddress: hexBytesArb({ minLength: 20, maxLength: 20 }), + value: fc.bigUintN(256), + callData: bufferArb(), + }) + .map( + ({ executerAddress, callAddress, value, callData }) => + new EvmExecute( + header.targetChainId, + executerAddress, + callAddress, + value, + callData + ) + ); } else { throw new Error("Unsupported action type"); } diff --git a/governance/xc_admin/packages/xc_admin_common/src/governance_payload/BufferLayoutExt.ts b/governance/xc_admin/packages/xc_admin_common/src/governance_payload/BufferLayoutExt.ts index 3b137420..c95ce6b3 100644 --- a/governance/xc_admin/packages/xc_admin_common/src/governance_payload/BufferLayoutExt.ts +++ b/governance/xc_admin/packages/xc_admin_common/src/governance_payload/BufferLayoutExt.ts @@ -1,25 +1,20 @@ import { Layout } from "@solana/buffer-layout"; +import { toBigIntBE, toBufferBE } from "bigint-buffer"; -export class UInt64BE extends Layout { +export class UIntBE extends Layout { + // span is the number of bytes to read constructor(span: number, property?: string) { super(span, property); } - // Note: we can not use read/writeBigUInt64BE because it is not supported in the browsers override decode(b: Uint8Array, offset?: number): bigint { let o = offset ?? 0; const buffer = Buffer.from(b.slice(o, o + this.span)); - const hi32 = buffer.readUInt32BE(); - const lo32 = buffer.readUInt32BE(4); - return BigInt(lo32) + (BigInt(hi32) << BigInt(32)); + return toBigIntBE(buffer); } override encode(src: bigint, b: Uint8Array, offset?: number): number { - const buffer = Buffer.alloc(this.span); - const hi32 = Number(src >> BigInt(32)); - const lo32 = Number(src & BigInt(0xffffffff)); - buffer.writeUInt32BE(hi32, 0); - buffer.writeUInt32BE(lo32, 4); + const buffer = toBufferBE(src, this.span); b.set(buffer, offset); return this.span; } @@ -45,8 +40,13 @@ export class HexBytes extends Layout { } /** A big-endian u64, returned as a bigint. */ -export function u64be(property?: string | undefined): UInt64BE { - return new UInt64BE(8, property); +export function u64be(property?: string | undefined): UIntBE { + return new UIntBE(8, property); +} + +/** A big-endian u256, returned as a bigint. */ +export function u256be(property?: string | undefined): UIntBE { + return new UIntBE(32, property); } /** An array of numBytes bytes, returned as a hexadecimal string. */ diff --git a/governance/xc_admin/packages/xc_admin_common/src/governance_payload/ExecuteAction.ts b/governance/xc_admin/packages/xc_admin_common/src/governance_payload/ExecuteAction.ts new file mode 100644 index 00000000..f10aef97 --- /dev/null +++ b/governance/xc_admin/packages/xc_admin_common/src/governance_payload/ExecuteAction.ts @@ -0,0 +1,73 @@ +import { PythGovernanceActionImpl } from "./PythGovernanceAction"; +import * as BufferLayout from "@solana/buffer-layout"; +import * as BufferLayoutExt from "./BufferLayoutExt"; +import { ChainName } from "../chains"; + +/** Executes an action from the executor contract via the specified executorAddress, callAddress, value, and calldata */ +export class EvmExecute extends PythGovernanceActionImpl { + static layout: BufferLayout.Structure< + Readonly<{ + executorAddress: string; + callAddress: string; + value: bigint; + calldata: Uint8Array; + }> + > = BufferLayout.struct([ + BufferLayoutExt.hexBytes(20, "executorAddress"), + BufferLayoutExt.hexBytes(20, "callAddress"), + BufferLayoutExt.u256be("value"), + BufferLayout.blob(new BufferLayout.GreedyCount(), "calldata"), + ]); + + constructor( + targetChainId: ChainName, + readonly executorAddress: string, + readonly callAddress: string, + readonly value: bigint, + readonly calldata: Buffer + ) { + super(targetChainId, "Execute"); + } + + static decode(data: Buffer): EvmExecute | undefined { + const decoded = PythGovernanceActionImpl.decodeWithPayload( + data, + "Execute", + this.layout + ); + if (!decoded) return undefined; + + return new EvmExecute( + decoded[0].targetChainId, + decoded[1].executorAddress, + decoded[1].callAddress, + decoded[1].value, + Buffer.from(decoded[1].calldata) + ); + } + + encode(): Buffer { + // encodeWithPayload creates a buffer using layout.span but EvmExecute.layout span is -1 + // because the calldata length is unknown. So we create a layout with a known calldata length + // and use that for encoding + const layout_with_known_span: BufferLayout.Structure< + Readonly<{ + executorAddress: string; + callAddress: string; + value: bigint; + calldata: Uint8Array; + }> + > = BufferLayout.struct([ + BufferLayoutExt.hexBytes(20, "executorAddress"), + BufferLayoutExt.hexBytes(20, "callAddress"), + BufferLayoutExt.u256be("value"), + BufferLayout.blob(this.calldata.length, "calldata"), + ]); + return super.encodeWithPayload(layout_with_known_span, { + executorAddress: this.executorAddress, + callAddress: this.callAddress, + value: this.value, + calldata: this.calldata, + }); + } +} 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 5d46fa30..e14d752b 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 @@ -17,6 +17,10 @@ export const TargetAction = { SetWormholeAddress: 6, } as const; +export const EvmExecutorAction = { + Execute: 0, +} as const; + /** Helper to get the ActionName from a (moduleId, actionId) tuple*/ export function toActionName( deserialized: Readonly<{ moduleId: number; actionId: number }> @@ -40,13 +44,19 @@ export function toActionName( case 6: return "SetWormholeAddress"; } + } else if ( + deserialized.moduleId == MODULE_EVM_EXECUTOR && + deserialized.actionId == 0 + ) { + return "Execute"; } return undefined; } export declare type ActionName = | keyof typeof ExecutorAction - | keyof typeof TargetAction; + | keyof typeof TargetAction + | keyof typeof EvmExecutorAction; /** Governance header that should be in every Pyth crosschain governance message*/ export class PythGovernanceHeader { @@ -109,9 +119,12 @@ export class PythGovernanceHeader { if (this.action in ExecutorAction) { module = MODULE_EXECUTOR; action = ExecutorAction[this.action as keyof typeof ExecutorAction]; - } else { + } else if (this.action in TargetAction) { module = MODULE_TARGET; action = TargetAction[this.action as keyof typeof TargetAction]; + } else { + module = MODULE_EVM_EXECUTOR; + action = EvmExecutorAction[this.action as keyof typeof EvmExecutorAction]; } if (toChainId(this.targetChainId) === undefined) throw new Error(`Invalid chain id ${this.targetChainId}`); @@ -131,10 +144,12 @@ export class PythGovernanceHeader { export const MAGIC_NUMBER = 0x4d475450; export const MODULE_EXECUTOR = 0; export const MODULE_TARGET = 1; -export const MODULES = [MODULE_EXECUTOR, MODULE_TARGET]; +export const MODULE_EVM_EXECUTOR = 2; +export const MODULES = [MODULE_EXECUTOR, MODULE_TARGET, MODULE_EVM_EXECUTOR]; export interface PythGovernanceAction { readonly targetChainId: ChainName; + encode(): Buffer; } 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 19769e05..2fd73c87 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 @@ -16,6 +16,7 @@ import { SetDataSources } from "./SetDataSources"; import { SetValidPeriod } from "./SetValidPeriod"; import { SetFee } from "./SetFee"; import { EvmSetWormholeAddress } from "./SetWormholeAddress"; +import { EvmExecute } from "./ExecuteAction"; /** Decode a governance payload */ export function decodeGovernancePayload( @@ -53,6 +54,8 @@ export function decodeGovernancePayload( return RequestGovernanceDataSourceTransfer.decode(data); case "SetWormholeAddress": return EvmSetWormholeAddress.decode(data); + case "Execute": + return EvmExecute.decode(data); default: return undefined; } @@ -67,3 +70,4 @@ export * from "./SetDataSources"; export * from "./SetValidPeriod"; export * from "./SetFee"; export * from "./SetWormholeAddress"; +export * from "./ExecuteAction"; diff --git a/package-lock.json b/package-lock.json index 0b8e2000..d1aa23b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1918,6 +1918,7 @@ "@solana/buffer-layout": "^4.0.1", "@solana/web3.js": "^1.73.0", "@sqds/mesh": "^1.0.6", + "bigint-buffer": "^1.1.5", "ethers": "^5.7.2", "lodash": "^4.17.21", "typescript": "^4.9.4" @@ -104649,6 +104650,7 @@ "@types/bn.js": "^5.1.1", "@types/jest": "^29.2.5", "@types/lodash": "^4.14.191", + "bigint-buffer": "*", "ethers": "^5.7.2", "fast-check": "^3.10.0", "jest": "^29.3.1",