diff --git a/.github/workflows/xc-admin-frontend-image-push.yaml b/.github/workflows/xc-admin-frontend-image-push.yaml index 34747146..3be5c78c 100644 --- a/.github/workflows/xc-admin-frontend-image-push.yaml +++ b/.github/workflows/xc-admin-frontend-image-push.yaml @@ -1,4 +1,4 @@ -name: Build and Push Cross Chain Admin Frontend +name: xc_admin_frontend Docker Image on: pull_request: push: diff --git a/governance/xc_admin/packages/xc_admin_common/package.json b/governance/xc_admin/packages/xc_admin_common/package.json index a0790194..16d3c721 100644 --- a/governance/xc_admin/packages/xc_admin_common/package.json +++ b/governance/xc_admin/packages/xc_admin_common/package.json @@ -38,6 +38,7 @@ "@types/lodash": "^4.14.191", "jest": "^29.3.1", "prettier": "^2.8.1", - "ts-jest": "^29.0.3" + "ts-jest": "^29.0.3", + "fast-check": "^3.10.0" } } 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 eb8af061..94801377 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 @@ -1,5 +1,39 @@ import { PublicKey, SystemProgram } from "@solana/web3.js"; -import { PythGovernanceHeader, ExecutePostedVaa } from ".."; +import { + PythGovernanceHeader, + ExecutePostedVaa, + MODULES, + MODULE_EXECUTOR, + TargetAction, + ExecutorAction, + ActionName, + PythGovernanceAction, + decodeGovernancePayload, +} from ".."; +import * as fc from "fast-check"; +import { + ChainId, + ChainName, + CHAINS, + toChainId, + toChainName, +} from "@certusone/wormhole-sdk"; +import { Arbitrary, IntArrayConstraints } from "fast-check"; +import { + AptosAuthorizeUpgradeContract, + CosmosUpgradeContract, + EvmUpgradeContract, +} from "../governance_payload/UpgradeContract"; +import { + AuthorizeGovernanceDataSourceTransfer, + RequestGovernanceDataSourceTransfer, +} from "../governance_payload/GovernanceDataSourceTransfer"; +import { SetFee } from "../governance_payload/SetFee"; +import { SetValidPeriod } from "../governance_payload/SetValidPeriod"; +import { + DataSource, + SetDataSources, +} from "../governance_payload/SetDataSources"; test("GovernancePayload ser/de", (done) => { jest.setTimeout(60000); @@ -121,3 +155,142 @@ test("GovernancePayload ser/de", (done) => { done(); }); + +/** Fastcheck generator for arbitrary PythGovernanceHeaders */ +function governanceHeaderArb(): Arbitrary { + const actions = [ + ...Object.keys(ExecutorAction), + ...Object.keys(TargetAction), + ] as ActionName[]; + const actionArb = fc.constantFrom(...actions); + const targetChainIdArb = fc.constantFrom( + ...(Object.keys(CHAINS) as ChainName[]) + ); + + return actionArb.chain((action) => { + return targetChainIdArb.chain((chainId) => { + return fc.constant(new PythGovernanceHeader(chainId, action)); + }); + }); +} + +/** Fastcheck generator for arbitrary Buffers */ +function bufferArb(constraints?: IntArrayConstraints): Arbitrary { + return fc.uint8Array(constraints).map((a) => Buffer.from(a)); +} + +/** Fastcheck generator for a uint of numBits bits. Warning: don't pass numBits > float precision */ +function uintArb(numBits: number): Arbitrary { + return fc.bigUintN(numBits).map((x) => Number.parseInt(x.toString())); +} + +/** Fastcheck generator for a byte array encoded as a hex string. */ +function hexBytesArb(constraints?: IntArrayConstraints): Arbitrary { + return fc.uint8Array(constraints).map((a) => Buffer.from(a).toString("hex")); +} + +function dataSourceArb(): Arbitrary { + return fc.record({ + emitterChain: uintArb(16), + emitterAddress: hexBytesArb({ minLength: 32, maxLength: 32 }), + }); +} + +/** + * Fastcheck generator for arbitrary PythGovernanceActions. + * + * Note that this generator doesn't generate ExecutePostedVaa instruction payloads because they're hard to generate. + */ +function governanceActionArb(): Arbitrary { + return governanceHeaderArb().chain((header) => { + if (header.action === "ExecutePostedVaa") { + // NOTE: the instructions field is hard to generatively test, so we're using the hardcoded + // tests above instead. + return fc.constant(new ExecutePostedVaa(header.targetChainId, [])); + } else if (header.action === "UpgradeContract") { + const cosmosArb = fc.bigUintN(64).map((codeId) => { + return new CosmosUpgradeContract(header.targetChainId, codeId); + }); + const aptosArb = hexBytesArb({ minLength: 32, maxLength: 32 }).map( + (buffer) => { + return new AptosAuthorizeUpgradeContract( + header.targetChainId, + buffer + ); + } + ); + const evmArb = hexBytesArb({ minLength: 20, maxLength: 20 }).map( + (address) => { + return new EvmUpgradeContract(header.targetChainId, address); + } + ); + + return fc.oneof(cosmosArb, aptosArb, evmArb); + } else if (header.action === "AuthorizeGovernanceDataSourceTransfer") { + return bufferArb().map((claimVaa) => { + return new AuthorizeGovernanceDataSourceTransfer( + header.targetChainId, + claimVaa + ); + }); + } else if (header.action === "SetDataSources") { + return fc.array(dataSourceArb()).map((dataSources) => { + return new SetDataSources(header.targetChainId, dataSources); + }); + } else if (header.action === "SetFee") { + return fc + .record({ v: fc.bigUintN(64), e: fc.bigUintN(64) }) + .map(({ v, e }) => { + return new SetFee(header.targetChainId, v, e); + }); + } else if (header.action === "SetValidPeriod") { + return fc.bigUintN(64).map((period) => { + return new SetValidPeriod(header.targetChainId, period); + }); + } else if (header.action === "RequestGovernanceDataSourceTransfer") { + return fc.bigUintN(32).map((index) => { + return new RequestGovernanceDataSourceTransfer( + header.targetChainId, + parseInt(index.toString()) + ); + }); + } else { + throw new Error("Unsupported action type"); + } + }); +} + +test("Header serialization round-trip test", (done) => { + fc.assert( + fc.property(governanceHeaderArb(), (original) => { + const decoded = PythGovernanceHeader.decode(original.encode()); + if (decoded === undefined) { + return false; + } + + return ( + decoded.action === original.action && + decoded.targetChainId === original.targetChainId + ); + }) + ); + + done(); +}); + +test("Governance action serialization round-trip test", (done) => { + fc.assert( + fc.property(governanceActionArb(), (original) => { + const encoded = original.encode(); + const decoded = decodeGovernancePayload(encoded); + if (decoded === undefined) { + return false; + } + + // TODO: not sure if i love this test. + return decoded.encode().equals(original.encode()); + }) + ); + + done(); +}); 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 new file mode 100644 index 00000000..33aaec0a --- /dev/null +++ b/governance/xc_admin/packages/xc_admin_common/src/governance_payload/BufferLayoutExt.ts @@ -0,0 +1,51 @@ +import { Layout } from "@solana/buffer-layout"; + +export class UInt64BE extends Layout { + constructor(span: number, property?: string) { + super(span, property); + } + + override decode(b: Uint8Array, offset?: number): bigint { + let o = offset ?? 0; + return Buffer.from(b.slice(o, o + this.span)).readBigUInt64BE(); + } + + override encode(src: bigint, b: Uint8Array, offset?: number): number { + const buffer = Buffer.alloc(this.span); + buffer.writeBigUint64BE(src); + b.set(buffer, offset); + return this.span; + } +} + +export class HexBytes extends Layout { + // span is the number of bytes to read + constructor(span: number, property?: string) { + super(span, property); + } + + override decode(b: Uint8Array, offset?: number): string { + let o = offset ?? 0; + return Buffer.from(b.slice(o, o + this.span)).toString("hex"); + } + + override encode(src: string, b: Uint8Array, offset?: number): number { + const buffer = Buffer.alloc(this.span); + buffer.write(src, "hex"); + b.set(buffer, offset); + return this.span; + } +} + +/** A big-endian u64, returned as a bigint. */ +export function u64be(property?: string | undefined): UInt64BE { + return new UInt64BE(8, property); +} + +/** An array of numBytes bytes, returned as a hexadecimal string. */ +export function hexBytes( + numBytes: number, + property?: string | undefined +): HexBytes { + return new HexBytes(numBytes, property); +} diff --git a/governance/xc_admin/packages/xc_admin_common/src/governance_payload/GovernanceDataSourceTransfer.ts b/governance/xc_admin/packages/xc_admin_common/src/governance_payload/GovernanceDataSourceTransfer.ts new file mode 100644 index 00000000..3edb1685 --- /dev/null +++ b/governance/xc_admin/packages/xc_admin_common/src/governance_payload/GovernanceDataSourceTransfer.ts @@ -0,0 +1,84 @@ +import { + ActionName, + PythGovernanceAction, + PythGovernanceActionImpl, + PythGovernanceHeader, +} from "./PythGovernanceAction"; +import * as BufferLayout from "@solana/buffer-layout"; +import { ChainName } from "@certusone/wormhole-sdk"; + +/** + * Authorize transferring the governance data source from the sender's emitter address to another emitter. + * The receiving emitter address is the sender of claimVaa, which must be a RequestGovernanceDataSourceTransfer message. + */ +export class AuthorizeGovernanceDataSourceTransfer + implements PythGovernanceAction +{ + readonly actionName: ActionName; + readonly claimVaa: Buffer; + + constructor(readonly targetChainId: ChainName, vaa: Buffer) { + this.actionName = "AuthorizeGovernanceDataSourceTransfer"; + this.claimVaa = new Buffer(vaa); + } + + static decode( + data: Buffer + ): AuthorizeGovernanceDataSourceTransfer | undefined { + const header = PythGovernanceHeader.decode(data); + if (!header || header.action !== "AuthorizeGovernanceDataSourceTransfer") { + return undefined; + } + + const payload = data.subarray(PythGovernanceHeader.span, data.length); + + return new AuthorizeGovernanceDataSourceTransfer( + header.targetChainId, + payload + ); + } + + encode(): Buffer { + const headerBuffer = new PythGovernanceHeader( + this.targetChainId, + this.actionName + ).encode(); + return Buffer.concat([headerBuffer, this.claimVaa]); + } +} + +/** + * Request a transfer of the governance data source to the emitter of this message. + */ +export class RequestGovernanceDataSourceTransfer extends PythGovernanceActionImpl { + static layout: BufferLayout.Structure< + Readonly<{ governanceDataSourceIndex: number }> + > = BufferLayout.struct([BufferLayout.u32be()]); + + constructor( + targetChainId: ChainName, + readonly governanceDataSourceIndex: number + ) { + super(targetChainId, "RequestGovernanceDataSourceTransfer"); + } + + static decode(data: Buffer): RequestGovernanceDataSourceTransfer | undefined { + const decoded = PythGovernanceActionImpl.decodeWithPayload( + data, + "RequestGovernanceDataSourceTransfer", + RequestGovernanceDataSourceTransfer.layout + ); + if (!decoded) return undefined; + + return new RequestGovernanceDataSourceTransfer( + decoded[0].targetChainId, + decoded[1].governanceDataSourceIndex + ); + } + + encode(): Buffer { + return super.encodeWithPayload(RequestGovernanceDataSourceTransfer.layout, { + governanceDataSourceIndex: this.governanceDataSourceIndex, + }); + } +} 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 new file mode 100644 index 00000000..36715407 --- /dev/null +++ b/governance/xc_admin/packages/xc_admin_common/src/governance_payload/PythGovernanceAction.ts @@ -0,0 +1,198 @@ +import { + ChainId, + ChainName, + toChainId, + toChainName, +} from "@certusone/wormhole-sdk"; +import * as BufferLayout from "@solana/buffer-layout"; +import { PACKET_DATA_SIZE } from "@solana/web3.js"; + +/** Each of the actions that can be directed to the Executor Module */ +export const ExecutorAction = { + ExecutePostedVaa: 0, +} as const; + +export const TargetAction = { + UpgradeContract: 0, + AuthorizeGovernanceDataSourceTransfer: 1, + SetDataSources: 2, + SetFee: 3, + SetValidPeriod: 4, + RequestGovernanceDataSourceTransfer: 5, +} as const; + +/** Helper to get the ActionName from a (moduleId, actionId) tuple*/ +export function toActionName( + deserialized: Readonly<{ moduleId: number; actionId: number }> +): ActionName | undefined { + if (deserialized.moduleId == MODULE_EXECUTOR && deserialized.actionId == 0) { + return "ExecutePostedVaa"; + } else if (deserialized.moduleId == MODULE_TARGET) { + switch (deserialized.actionId) { + case 0: + return "UpgradeContract"; + case 1: + return "AuthorizeGovernanceDataSourceTransfer"; + case 2: + return "SetDataSources"; + case 3: + return "SetFee"; + case 4: + return "SetValidPeriod"; + case 5: + return "RequestGovernanceDataSourceTransfer"; + } + } + return undefined; +} + +export declare type ActionName = + | keyof typeof ExecutorAction + | keyof typeof TargetAction; + +/** Governance header that should be in every Pyth crosschain governance message*/ +export class PythGovernanceHeader { + readonly targetChainId: ChainName; + readonly action: ActionName; + static layout: BufferLayout.Structure< + Readonly<{ + magicNumber: number; + module: number; + action: number; + chain: ChainId; + }> + > = BufferLayout.struct( + [ + BufferLayout.u32("magicNumber"), + BufferLayout.u8("module"), + BufferLayout.u8("action"), + BufferLayout.u16be("chain"), + ], + "header" + ); + /** Span of the serialized governance header */ + static span = 8; + + constructor(targetChainId: ChainName, action: ActionName) { + this.targetChainId = targetChainId; + this.action = action; + } + /** Decode Pyth Governance Header */ + static decode(data: Buffer): PythGovernanceHeader | undefined { + const deserialized = safeLayoutDecode(this.layout, data); + + if (!deserialized) return undefined; + + if (deserialized.magicNumber !== MAGIC_NUMBER) return undefined; + + if (!toChainName(deserialized.chain)) return undefined; + + const actionName = toActionName({ + actionId: deserialized.action, + moduleId: deserialized.module, + }); + + if (actionName) { + return new PythGovernanceHeader( + toChainName(deserialized.chain), + actionName + ); + } else { + return undefined; + } + } + + /** Encode Pyth Governance Header */ + encode(): Buffer { + // The code will crash if the payload is actually bigger than PACKET_DATA_SIZE. But PACKET_DATA_SIZE is the maximum transaction size of Solana, so our serialized payload should never be bigger than this anyway + const buffer = Buffer.alloc(PACKET_DATA_SIZE); + let module: number; + let action: number; + if (this.action in ExecutorAction) { + module = MODULE_EXECUTOR; + action = ExecutorAction[this.action as keyof typeof ExecutorAction]; + } else { + module = MODULE_TARGET; + action = TargetAction[this.action as keyof typeof TargetAction]; + } + const span = PythGovernanceHeader.layout.encode( + { + magicNumber: MAGIC_NUMBER, + module, + action, + chain: toChainId(this.targetChainId), + }, + buffer + ); + return buffer.subarray(0, span); + } +} + +export const MAGIC_NUMBER = 0x4d475450; +export const MODULE_EXECUTOR = 0; +export const MODULE_TARGET = 1; +export const MODULES = [MODULE_EXECUTOR, MODULE_TARGET]; + +export interface PythGovernanceAction { + readonly targetChainId: ChainName; + encode(): Buffer; +} + +/** Helper class for implementing PythGovernanceAction using a BufferLayout.Layout for the payload. */ +export abstract class PythGovernanceActionImpl implements PythGovernanceAction { + readonly targetChainId: ChainName; + readonly action: ActionName; + + protected constructor(targetChainId: ChainName, action: ActionName) { + this.targetChainId = targetChainId; + this.action = action; + } + + abstract encode(): Buffer; + + protected header(): PythGovernanceHeader { + return new PythGovernanceHeader(this.targetChainId, this.action); + } + + /** Encode this action as a buffer with the given payload (encoded using the given layout). */ + protected encodeWithPayload( + payloadLayout: BufferLayout.Layout, + payload: T + ): Buffer { + const headerBuffer = this.header().encode(); + + const payloadBuffer = Buffer.alloc(payloadLayout.span); + payloadLayout.encode(payload, payloadBuffer); + + return Buffer.concat([headerBuffer, payloadBuffer]); + } + + /** Decode this action from a buffer using the given layout for the payload. */ + protected static decodeWithPayload( + buffer: Buffer, + requiredAction: ActionName, + payloadLayout: BufferLayout.Layout + ): [PythGovernanceHeader, T] | undefined { + const header = PythGovernanceHeader.decode(buffer); + if (!header || header.action !== requiredAction) return undefined; + + const payload = safeLayoutDecode( + payloadLayout, + buffer.subarray(PythGovernanceHeader.span, buffer.length) + ); + if (!payload) return undefined; + + return [header, payload]; + } +} + +export function safeLayoutDecode( + layout: BufferLayout.Layout, + data: Buffer +): T | undefined { + try { + return layout.decode(data); + } catch { + return undefined; + } +} diff --git a/governance/xc_admin/packages/xc_admin_common/src/governance_payload/SetDataSources.ts b/governance/xc_admin/packages/xc_admin_common/src/governance_payload/SetDataSources.ts new file mode 100644 index 00000000..a0e2b15a --- /dev/null +++ b/governance/xc_admin/packages/xc_admin_common/src/governance_payload/SetDataSources.ts @@ -0,0 +1,68 @@ +import { + ActionName, + PythGovernanceAction, + PythGovernanceActionImpl, + PythGovernanceHeader, +} from "./PythGovernanceAction"; +import { ChainName } from "@certusone/wormhole-sdk"; +import * as BufferLayout from "@solana/buffer-layout"; +import * as BufferLayoutExt from "./BufferLayoutExt"; + +/** A data source is a wormhole emitter, i.e., a specific contract on a specific chain. */ +export interface DataSource { + emitterChain: number; + emitterAddress: string; +} +const DataSourceLayout: BufferLayout.Structure = + BufferLayout.struct([ + BufferLayout.u16be("emitterChain"), + BufferLayoutExt.hexBytes(32, "emitterAddress"), + ]); + +/** Set the data sources (where price updates must come from) on targetChainId to the provided values. */ +export class SetDataSources implements PythGovernanceAction { + readonly actionName: ActionName; + + constructor( + readonly targetChainId: ChainName, + readonly dataSources: DataSource[] + ) { + this.actionName = "SetDataSources"; + } + + static decode(data: Buffer): SetDataSources | undefined { + const header = PythGovernanceHeader.decode(data); + if (!header || header.action !== "SetDataSources") { + return undefined; + } + + let index = PythGovernanceHeader.span; + const numSources = BufferLayout.u8().decode(data, index); + index += 1; + const dataSources = []; + for (let i = 0; i < numSources; i++) { + dataSources.push(DataSourceLayout.decode(data, index)); + index += DataSourceLayout.span; + } + + return new SetDataSources(header.targetChainId, dataSources); + } + + encode(): Buffer { + const headerBuffer = new PythGovernanceHeader( + this.targetChainId, + "SetDataSources" + ).encode(); + + const numSourcesBuf = Buffer.alloc(1); + BufferLayout.u8().encode(this.dataSources.length, numSourcesBuf); + + const dataSourceBufs = this.dataSources.map((source) => { + const buf = Buffer.alloc(DataSourceLayout.span); + DataSourceLayout.encode(source, buf); + return buf; + }); + + return Buffer.concat([headerBuffer, numSourcesBuf, ...dataSourceBufs]); + } +} diff --git a/governance/xc_admin/packages/xc_admin_common/src/governance_payload/SetFee.ts b/governance/xc_admin/packages/xc_admin_common/src/governance_payload/SetFee.ts new file mode 100644 index 00000000..d2b2f9c9 --- /dev/null +++ b/governance/xc_admin/packages/xc_admin_common/src/governance_payload/SetFee.ts @@ -0,0 +1,44 @@ +import { PythGovernanceActionImpl } from "./PythGovernanceAction"; +import * as BufferLayout from "@solana/buffer-layout"; +import * as BufferLayoutExt from "./BufferLayoutExt"; +import { ChainName } from "@certusone/wormhole-sdk"; + +/** Set the fee on the target chain to newFeeValue * 10^newFeeExpo */ +export class SetFee 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, "SetFee"); + } + + static decode(data: Buffer): SetFee | undefined { + const decoded = PythGovernanceActionImpl.decodeWithPayload( + data, + "SetFee", + SetFee.layout + ); + if (!decoded) return undefined; + + return new SetFee( + decoded[0].targetChainId, + decoded[1].newFeeValue, + decoded[1].newFeeExpo + ); + } + + encode(): Buffer { + return super.encodeWithPayload(SetFee.layout, { + newFeeValue: this.newFeeValue, + newFeeExpo: this.newFeeExpo, + }); + } +} diff --git a/governance/xc_admin/packages/xc_admin_common/src/governance_payload/SetValidPeriod.ts b/governance/xc_admin/packages/xc_admin_common/src/governance_payload/SetValidPeriod.ts new file mode 100644 index 00000000..a4477b78 --- /dev/null +++ b/governance/xc_admin/packages/xc_admin_common/src/governance_payload/SetValidPeriod.ts @@ -0,0 +1,37 @@ +import { + PythGovernanceActionImpl, + PythGovernanceHeader, +} from "./PythGovernanceAction"; +import * as BufferLayout from "@solana/buffer-layout"; +import * as BufferLayoutExt from "./BufferLayoutExt"; +import { ChainName } from "@certusone/wormhole-sdk"; + +/** Set the valid period (the default amount of time in which prices are considered fresh) to the provided value */ +export class SetValidPeriod extends PythGovernanceActionImpl { + static layout: BufferLayout.Structure> = + BufferLayout.struct([BufferLayoutExt.u64be("newValidPeriod")]); + + constructor(targetChainId: ChainName, readonly newValidPeriod: bigint) { + super(targetChainId, "SetValidPeriod"); + } + + static decode(data: Buffer): SetValidPeriod | undefined { + const decoded = PythGovernanceActionImpl.decodeWithPayload( + data, + "SetValidPeriod", + SetValidPeriod.layout + ); + if (!decoded) return undefined; + + return new SetValidPeriod( + decoded[0].targetChainId, + decoded[1].newValidPeriod + ); + } + + encode(): Buffer { + return super.encodeWithPayload(SetValidPeriod.layout, { + newValidPeriod: this.newValidPeriod, + }); + } +} diff --git a/governance/xc_admin/packages/xc_admin_common/src/governance_payload/UpgradeContract.ts b/governance/xc_admin/packages/xc_admin_common/src/governance_payload/UpgradeContract.ts index 5a392029..26ab565c 100644 --- a/governance/xc_admin/packages/xc_admin_common/src/governance_payload/UpgradeContract.ts +++ b/governance/xc_admin/packages/xc_admin_common/src/governance_payload/UpgradeContract.ts @@ -1,38 +1,90 @@ import { ChainName } from "@certusone/wormhole-sdk"; -import { PythGovernanceAction, PythGovernanceHeader } from "."; +import { PythGovernanceActionImpl } from "./PythGovernanceAction"; +import * as BufferLayout from "@solana/buffer-layout"; +import * as BufferLayoutExt from "./BufferLayoutExt"; -export class CosmosUpgradeContract implements PythGovernanceAction { - readonly targetChainId: ChainName; - readonly codeId: bigint; +/** Upgrade a cosmos contract to the implementation at codeId. (Note that this requires someone to upload the new + * contract code first to obtain a codeId.) */ +export class CosmosUpgradeContract extends PythGovernanceActionImpl { + static layout: BufferLayout.Structure> = + BufferLayout.struct([BufferLayoutExt.u64be("codeId")]); - constructor(targetChainId: ChainName, codeId: bigint) { - this.targetChainId = targetChainId; - this.codeId = codeId; + constructor(targetChainId: ChainName, readonly codeId: bigint) { + super(targetChainId, "UpgradeContract"); } - static span: number = 8; static decode(data: Buffer): CosmosUpgradeContract | undefined { - const header = PythGovernanceHeader.decode(data); - if (!header) return undefined; + const decoded = PythGovernanceActionImpl.decodeWithPayload( + data, + "UpgradeContract", + CosmosUpgradeContract.layout + ); + if (!decoded) return undefined; - const codeId = data.subarray(PythGovernanceHeader.span).readBigUInt64BE(); - if (!codeId) return undefined; - - return new CosmosUpgradeContract(header.targetChainId, codeId); + return new CosmosUpgradeContract( + decoded[0].targetChainId, + decoded[1].codeId + ); } - /** Encode CosmosUpgradeContract */ encode(): Buffer { - const headerBuffer = new PythGovernanceHeader( - this.targetChainId, - "UpgradeContract" - ).encode(); - - const buffer = Buffer.alloc( - PythGovernanceHeader.span + CosmosUpgradeContract.span - ); - - const span = buffer.writeBigUInt64BE(this.codeId); - return Buffer.concat([headerBuffer, buffer.subarray(0, span)]); + return super.encodeWithPayload(CosmosUpgradeContract.layout, { + codeId: this.codeId, + }); + } +} + +export class AptosAuthorizeUpgradeContract extends PythGovernanceActionImpl { + static layout: BufferLayout.Structure> = + BufferLayout.struct([BufferLayoutExt.hexBytes(32, "hash")]); + + constructor(targetChainId: ChainName, readonly hash: string) { + super(targetChainId, "UpgradeContract"); + } + + static decode(data: Buffer): AptosAuthorizeUpgradeContract | undefined { + const decoded = PythGovernanceActionImpl.decodeWithPayload( + data, + "UpgradeContract", + this.layout + ); + if (!decoded) return undefined; + + return new AptosAuthorizeUpgradeContract( + decoded[0].targetChainId, + decoded[1].hash + ); + } + + encode(): Buffer { + return super.encodeWithPayload(AptosAuthorizeUpgradeContract.layout, { + hash: this.hash, + }); + } +} + +export class EvmUpgradeContract extends PythGovernanceActionImpl { + static layout: BufferLayout.Structure> = + BufferLayout.struct([BufferLayoutExt.hexBytes(20, "address")]); + + constructor(targetChainId: ChainName, readonly address: string) { + super(targetChainId, "UpgradeContract"); + } + + static decode(data: Buffer): EvmUpgradeContract | undefined { + const decoded = PythGovernanceActionImpl.decodeWithPayload( + data, + "UpgradeContract", + this.layout + ); + if (!decoded) return undefined; + + return new EvmUpgradeContract(decoded[0].targetChainId, decoded[1].address); + } + + encode(): Buffer { + return super.encodeWithPayload(EvmUpgradeContract.layout, { + address: this.address, + }); } } 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 1b78261e..1864ec02 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 @@ -1,143 +1,20 @@ -import { - ChainId, - ChainName, - toChainId, - toChainName, -} from "@certusone/wormhole-sdk"; -import * as BufferLayout from "@solana/buffer-layout"; -import { PACKET_DATA_SIZE } from "@solana/web3.js"; import { ExecutePostedVaa } from "./ExecutePostedVaa"; -import { CosmosUpgradeContract } from "./UpgradeContract"; - -export interface PythGovernanceAction { - readonly targetChainId: ChainName; - encode(): Buffer; -} - -/** Each of the actions that can be directed to the Executor Module */ -export const ExecutorAction = { - ExecutePostedVaa: 0, -} as const; - -export const TargetAction = { - UpgradeContract: 0, - AuthorizeGovernanceDataSourceTransfer: 1, - SetDataSources: 2, - SetFee: 3, - SetValidPeriod: 4, - RequestGovernanceDataSourceTransfer: 5, -} as const; - -/** Helper to get the ActionName from a (moduleId, actionId) tuple*/ -export function toActionName( - deserialized: Readonly<{ moduleId: number; actionId: number }> -): ActionName | undefined { - if (deserialized.moduleId == MODULE_EXECUTOR && deserialized.actionId == 0) { - return "ExecutePostedVaa"; - } else if (deserialized.moduleId == MODULE_TARGET) { - switch (deserialized.actionId) { - case 0: - return "UpgradeContract"; - case 1: - return "AuthorizeGovernanceDataSourceTransfer"; - case 2: - return "SetDataSources"; - case 3: - return "SetFee"; - case 4: - return "SetValidPeriod"; - case 5: - return "RequestGovernanceDataSourceTransfer"; - } - } - return undefined; -} - -export declare type ActionName = - | keyof typeof ExecutorAction - | keyof typeof TargetAction; - -/** Governance header that should be in every Pyth crosschain governance message*/ -export class PythGovernanceHeader { - readonly targetChainId: ChainName; - readonly action: ActionName; - static layout: BufferLayout.Structure< - Readonly<{ - magicNumber: number; - module: number; - action: number; - chain: ChainId; - }> - > = BufferLayout.struct( - [ - BufferLayout.u32("magicNumber"), - BufferLayout.u8("module"), - BufferLayout.u8("action"), - BufferLayout.u16be("chain"), - ], - "header" - ); - /** Span of the serialized governance header */ - static span = 8; - - constructor(targetChainId: ChainName, action: ActionName) { - this.targetChainId = targetChainId; - this.action = action; - } - /** Decode Pyth Governance Header */ - static decode(data: Buffer): PythGovernanceHeader | undefined { - const deserialized = safeLayoutDecode(this.layout, data); - - if (!deserialized) return undefined; - - if (deserialized.magicNumber !== MAGIC_NUMBER) return undefined; - - if (!toChainName(deserialized.chain)) return undefined; - - const actionName = toActionName({ - actionId: deserialized.action, - moduleId: deserialized.module, - }); - - if (actionName) { - return new PythGovernanceHeader( - toChainName(deserialized.chain), - actionName - ); - } else { - return undefined; - } - } - - /** Encode Pyth Governance Header */ - encode(): Buffer { - // The code will crash if the payload is actually bigger than PACKET_DATA_SIZE. But PACKET_DATA_SIZE is the maximum transaction size of Solana, so our serialized payload should never be bigger than this anyway - const buffer = Buffer.alloc(PACKET_DATA_SIZE); - let module: number; - let action: number; - if (this.action in ExecutorAction) { - module = MODULE_EXECUTOR; - action = ExecutorAction[this.action as keyof typeof ExecutorAction]; - } else { - module = MODULE_TARGET; - action = TargetAction[this.action as keyof typeof TargetAction]; - } - const span = PythGovernanceHeader.layout.encode( - { - magicNumber: MAGIC_NUMBER, - module, - action, - chain: toChainId(this.targetChainId), - }, - buffer - ); - return buffer.subarray(0, span); - } -} - -export const MAGIC_NUMBER = 0x4d475450; -export const MODULE_EXECUTOR = 0; -export const MODULE_TARGET = 1; +import { + AptosAuthorizeUpgradeContract, + CosmosUpgradeContract, + EvmUpgradeContract, +} from "./UpgradeContract"; +import { + PythGovernanceAction, + PythGovernanceHeader, +} from "./PythGovernanceAction"; +import { + AuthorizeGovernanceDataSourceTransfer, + RequestGovernanceDataSourceTransfer, +} from "./GovernanceDataSourceTransfer"; +import { SetDataSources } from "./SetDataSources"; +import { SetValidPeriod } from "./SetValidPeriod"; +import { SetFee } from "./SetFee"; /** Decode a governance payload */ export function decodeGovernancePayload( @@ -150,22 +27,33 @@ export function decodeGovernancePayload( case "ExecutePostedVaa": return ExecutePostedVaa.decode(data); case "UpgradeContract": - //TO DO : Support non-cosmos upgrades - return CosmosUpgradeContract.decode(data); + // NOTE: the only way to distinguish the different types of upgrade contract instructions + // is their payload lengths. We're getting a little lucky here that all of these upgrade instructions + // have different-length payloads. + const payloadLength = data.length - PythGovernanceHeader.span; + if (payloadLength == CosmosUpgradeContract.layout.span) { + return CosmosUpgradeContract.decode(data); + } else if (payloadLength == AptosAuthorizeUpgradeContract.layout.span) { + return AptosAuthorizeUpgradeContract.decode(data); + } else if (payloadLength == EvmUpgradeContract.layout.span) { + return EvmUpgradeContract.decode(data); + } else { + return undefined; + } + case "AuthorizeGovernanceDataSourceTransfer": + return AuthorizeGovernanceDataSourceTransfer.decode(data); + case "SetDataSources": + return SetDataSources.decode(data); + case "SetFee": + return SetFee.decode(data); + case "SetValidPeriod": + return SetValidPeriod.decode(data); + case "RequestGovernanceDataSourceTransfer": + return RequestGovernanceDataSourceTransfer.decode(data); default: return undefined; } } -export function safeLayoutDecode( - layout: BufferLayout.Layout, - data: Buffer -): T | undefined { - try { - return layout.decode(data); - } catch { - return undefined; - } -} - export { ExecutePostedVaa } from "./ExecutePostedVaa"; +export * from "./PythGovernanceAction"; diff --git a/package-lock.json b/package-lock.json index 953f3909..e37ae862 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1349,6 +1349,7 @@ "@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" @@ -1389,6 +1390,28 @@ "follow-redirects": "^1.14.4" } }, + "governance/xc_admin/packages/xc_admin_common/node_modules/fast-check": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.10.0.tgz", + "integrity": "sha512-I2FldZwnCbcY6iL+H0rp9m4D+O3PotuFu9FasWjMCzUedYHMP89/37JbSt6/n7Yq/IZmJDW0B2h30sPYdzrfzw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "dependencies": { + "pure-rand": "^6.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "governance/xc_admin/packages/xc_admin_common/node_modules/prettier": { "version": "2.8.3", "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.3.tgz", @@ -1404,6 +1427,22 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "governance/xc_admin/packages/xc_admin_common/node_modules/pure-rand": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.0.2.tgz", + "integrity": "sha512-6Yg0ekpKICSjPswYOuC5sku/TSWaRYlA0qsXqJgM/d/4pLPHPuTxK7Nbf7jFKzAeedUhR8C7K9Uv63FBsSo8xQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ] + }, "governance/xc_admin/packages/xc_admin_frontend": { "version": "0.1.0", "dependencies": { @@ -106805,6 +106844,7 @@ "@types/jest": "^29.2.5", "@types/lodash": "^4.14.191", "ethers": "^5.7.2", + "fast-check": "^3.10.0", "jest": "^29.3.1", "lodash": "^4.17.21", "prettier": "^2.8.1", @@ -106847,11 +106887,26 @@ "follow-redirects": "^1.14.4" } }, + "fast-check": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.10.0.tgz", + "integrity": "sha512-I2FldZwnCbcY6iL+H0rp9m4D+O3PotuFu9FasWjMCzUedYHMP89/37JbSt6/n7Yq/IZmJDW0B2h30sPYdzrfzw==", + "dev": true, + "requires": { + "pure-rand": "^6.0.0" + } + }, "prettier": { "version": "2.8.3", "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.3.tgz", "integrity": "sha512-tJ/oJ4amDihPoufT5sM0Z1SKEuKay8LfVAMlbbhnnkvt6BUserZylqo2PN+p9KeljLr0OHa2rXHU1T8reeoTrw==", "dev": true + }, + "pure-rand": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.0.2.tgz", + "integrity": "sha512-6Yg0ekpKICSjPswYOuC5sku/TSWaRYlA0qsXqJgM/d/4pLPHPuTxK7Nbf7jFKzAeedUhR8C7K9Uv63FBsSo8xQ==", + "dev": true } } },