diff --git a/governance/xc_admin/packages/xc_admin_common/package.json b/governance/xc_admin/packages/xc_admin_common/package.json index f0c60c5a79..83117feeaa 100644 --- a/governance/xc_admin/packages/xc_admin_common/package.json +++ b/governance/xc_admin/packages/xc_admin_common/package.json @@ -228,6 +228,16 @@ "default": "./dist/esm/governance_payload/SetWormholeAddress.mjs" } }, + "./governance_payload/UpdateTrustedSigner": { + "require": { + "types": "./dist/cjs/governance_payload/UpdateTrustedSigner.d.ts", + "default": "./dist/cjs/governance_payload/UpdateTrustedSigner.cjs" + }, + "import": { + "types": "./dist/esm/governance_payload/UpdateTrustedSigner.d.ts", + "default": "./dist/esm/governance_payload/UpdateTrustedSigner.mjs" + } + }, "./governance_payload/UpgradeContract": { "require": { "types": "./dist/cjs/governance_payload/UpgradeContract.d.ts", @@ -238,6 +248,16 @@ "default": "./dist/esm/governance_payload/UpgradeContract.mjs" } }, + "./governance_payload/UpgradeLazerContract": { + "require": { + "types": "./dist/cjs/governance_payload/UpgradeLazerContract.d.ts", + "default": "./dist/cjs/governance_payload/UpgradeLazerContract.cjs" + }, + "import": { + "types": "./dist/esm/governance_payload/UpgradeLazerContract.d.ts", + "default": "./dist/esm/governance_payload/UpgradeLazerContract.mjs" + } + }, "./governance_payload/WithdrawFee": { "require": { "types": "./dist/cjs/governance_payload/WithdrawFee.d.ts", 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 d6013a01cc..43194e739d 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 @@ -11,6 +11,9 @@ import { EvmExecutorAction, EvmExecute, StarknetSetWormholeAddress, + LazerAction, + UpgradeLazerContract256Bit, + UpdateTrustedSigner264Bit, } from ".."; import * as fc from "fast-check"; import { type ChainName, CHAINS } from "../chains"; @@ -282,6 +285,39 @@ test("GovernancePayload ser/de", (done) => { ), ).toBeTruthy(); + const upgradeLazerContract = new UpgradeLazerContract256Bit( + "sui", + "043d0ed8155263af0862372df3af9403c502358661f317f62fbdc026d03beaee", + ); + const upgradeLazerContractBuffer = upgradeLazerContract.encode(); + console.log(upgradeLazerContractBuffer.toJSON()); + expect( + upgradeLazerContractBuffer.equals( + Buffer.from([ + 80, 84, 71, 77, 3, 0, 0, 21, 4, 61, 14, 216, 21, 82, 99, 175, 8, 98, 55, + 45, 243, 175, 148, 3, 197, 2, 53, 134, 97, 243, 23, 246, 47, 189, 192, + 38, 208, 59, 234, 238, + ]), + ), + ).toBeTruthy(); + + const updateTrustedSigner = new UpdateTrustedSigner264Bit( + "sui", + "03a4380f01136eb2640f90c17e1e319e02bbafbeef2e6e67dc48af53f9827e155b", + 10794n, + ); + const updateTrustedSignerBuffer = updateTrustedSigner.encode(); + console.log(updateTrustedSignerBuffer.toJSON()); + expect( + updateTrustedSignerBuffer.equals( + Buffer.from([ + 80, 84, 71, 77, 3, 1, 0, 21, 3, 164, 56, 15, 1, 19, 110, 178, 100, 15, + 144, 193, 126, 30, 49, 158, 2, 187, 175, 190, 239, 46, 110, 103, 220, + 72, 175, 83, 249, 130, 126, 21, 91, 0, 0, 0, 0, 0, 0, 42, 42, + ]), + ), + ).toBeTruthy(); + done(); }); @@ -291,6 +327,7 @@ function governanceHeaderArb(): Arbitrary { ...Object.keys(ExecutorAction), ...Object.keys(TargetAction), ...Object.keys(EvmExecutorAction), + ...Object.keys(LazerAction), ] as ActionName[]; const actionArb = fc.constantFrom(...actions); const targetChainIdArb = fc.constantFrom( @@ -451,6 +488,23 @@ function governanceActionArb(): Arbitrary { expo, ); }); + } else if (header.action === "UpgradeLazerContract") { + return hexBytesArb({ minLength: 32, maxLength: 32 }).map((buffer) => { + return new UpgradeLazerContract256Bit(header.targetChainId, buffer); + }); + } else if (header.action === "UpdateTrustedSigner") { + return fc + .record({ + publicKey: hexBytesArb({ minLength: 33, maxLength: 33 }), + expiresAt: fc.bigInt({ min: 0n, max: 2n ** 64n - 1n }), + }) + .map(({ publicKey, expiresAt }) => { + return new UpdateTrustedSigner264Bit( + header.targetChainId, + publicKey, + expiresAt, + ); + }); } 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 42e091dea6..dd99b70fed 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 @@ -29,6 +29,11 @@ export const EvmExecutorAction = { Execute: 0, } as const; +export const LazerAction = { + UpgradeLazerContract: 0, + UpdateTrustedSigner: 1, +}; + /** Helper to get the ActionName from a (moduleId, actionId) tuple*/ export function toActionName( deserialized: Readonly<{ moduleId: number; actionId: number }>, @@ -63,6 +68,13 @@ export function toActionName( deserialized.actionId == 0 ) { return "Execute"; + } else if (deserialized.moduleId == MODULE_LAZER) { + switch (deserialized.actionId) { + case 0: + return "UpgradeLazerContract"; + case 1: + return "UpdateTrustedSigner"; + } } return undefined; } @@ -70,7 +82,8 @@ export function toActionName( export declare type ActionName = | keyof typeof ExecutorAction | keyof typeof TargetAction - | keyof typeof EvmExecutorAction; + | keyof typeof EvmExecutorAction + | keyof typeof LazerAction; /** Governance header that should be in every Pyth crosschain governance message*/ export class PythGovernanceHeader { @@ -136,9 +149,12 @@ export class PythGovernanceHeader { } else if (this.action in TargetAction) { module = MODULE_TARGET; action = TargetAction[this.action as keyof typeof TargetAction]; - } else { + } else if (this.action in EvmExecutorAction) { module = MODULE_EVM_EXECUTOR; action = EvmExecutorAction[this.action as keyof typeof EvmExecutorAction]; + } else { + module = MODULE_LAZER; + action = LazerAction[this.action as keyof typeof LazerAction]; } if (toChainId(this.targetChainId) === undefined) throw new Error(`Invalid chain id ${this.targetChainId}`); @@ -159,7 +175,13 @@ export const MAGIC_NUMBER = 0x4d475450; export const MODULE_EXECUTOR = 0; export const MODULE_TARGET = 1; export const MODULE_EVM_EXECUTOR = 2; -export const MODULES = [MODULE_EXECUTOR, MODULE_TARGET, MODULE_EVM_EXECUTOR]; +export const MODULE_LAZER = 3; +export const MODULES = [ + MODULE_EXECUTOR, + MODULE_TARGET, + MODULE_EVM_EXECUTOR, + MODULE_LAZER, +]; export interface PythGovernanceAction { readonly targetChainId: ChainName; diff --git a/governance/xc_admin/packages/xc_admin_common/src/governance_payload/UpdateTrustedSigner.ts b/governance/xc_admin/packages/xc_admin_common/src/governance_payload/UpdateTrustedSigner.ts new file mode 100644 index 0000000000..f796d466d9 --- /dev/null +++ b/governance/xc_admin/packages/xc_admin_common/src/governance_payload/UpdateTrustedSigner.ts @@ -0,0 +1,44 @@ +import type { ChainName } from "../chains"; +import { PythGovernanceActionImpl } from "./PythGovernanceAction"; +import * as BufferLayout from "@solana/buffer-layout"; +import * as BufferLayoutExt from "./BufferLayoutExt"; + +// 33-byte signer address, used by Sui +export class UpdateTrustedSigner264Bit extends PythGovernanceActionImpl { + static layout: BufferLayout.Structure< + Readonly<{ publicKey: string; expiresAt: bigint }> + > = BufferLayout.struct([ + BufferLayoutExt.hexBytes(33, "publicKey"), + BufferLayoutExt.u64be("expiresAt"), + ]); + + constructor( + targetChainId: ChainName, + readonly publicKey: string, + readonly expiresAt: bigint, + ) { + super(targetChainId, "UpdateTrustedSigner"); + } + + static decode(data: Buffer): UpdateTrustedSigner264Bit | undefined { + const decoded = PythGovernanceActionImpl.decodeWithPayload( + data, + "UpdateTrustedSigner", + this.layout, + ); + if (!decoded) return undefined; + + return new UpdateTrustedSigner264Bit( + decoded[0].targetChainId, + decoded[1].publicKey, + decoded[1].expiresAt, + ); + } + + encode(): Buffer { + return super.encodeWithPayload(UpdateTrustedSigner264Bit.layout, { + publicKey: this.publicKey, + expiresAt: this.expiresAt, + }); + } +} diff --git a/governance/xc_admin/packages/xc_admin_common/src/governance_payload/UpgradeLazerContract.ts b/governance/xc_admin/packages/xc_admin_common/src/governance_payload/UpgradeLazerContract.ts new file mode 100644 index 0000000000..c96a30f77f --- /dev/null +++ b/governance/xc_admin/packages/xc_admin_common/src/governance_payload/UpgradeLazerContract.ts @@ -0,0 +1,37 @@ +import type { ChainName } from "../chains"; +import { PythGovernanceActionImpl } from "./PythGovernanceAction"; +import * as BufferLayout from "@solana/buffer-layout"; +import * as BufferLayoutExt from "./BufferLayoutExt"; + +// Used by Sui +export class UpgradeLazerContract256Bit extends PythGovernanceActionImpl { + static layout: BufferLayout.Structure> = + BufferLayout.struct([BufferLayoutExt.hexBytes(32, "hash")]); + + constructor( + targetChainId: ChainName, + readonly hash: string, + ) { + super(targetChainId, "UpgradeLazerContract"); + } + + static decode(data: Buffer): UpgradeLazerContract256Bit | undefined { + const decoded = PythGovernanceActionImpl.decodeWithPayload( + data, + "UpgradeLazerContract", + this.layout, + ); + if (!decoded) return undefined; + + return new UpgradeLazerContract256Bit( + decoded[0].targetChainId, + decoded[1].hash, + ); + } + + encode(): Buffer { + return super.encodeWithPayload(UpgradeLazerContract256Bit.layout, { + hash: this.hash, + }); + } +} 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 879c4b709e..cb59baa6ff 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 @@ -22,6 +22,8 @@ import { import { EvmExecute } from "./ExecuteAction"; import { SetTransactionFee } from "./SetTransactionFee"; import { WithdrawFee } from "./WithdrawFee"; +import { UpgradeLazerContract256Bit } from "./UpgradeLazerContract"; +import { UpdateTrustedSigner264Bit } from "./UpdateTrustedSigner"; /** Decode a governance payload */ export function decodeGovernancePayload( @@ -79,6 +81,10 @@ export function decodeGovernancePayload( return SetTransactionFee.decode(data); case "WithdrawFee": return WithdrawFee.decode(data); + case "UpgradeLazerContract": + return UpgradeLazerContract256Bit.decode(data); + case "UpdateTrustedSigner": + return UpdateTrustedSigner264Bit.decode(data); default: return undefined; } @@ -96,3 +102,5 @@ export * from "./SetTransactionFee"; export * from "./SetWormholeAddress"; export * from "./ExecuteAction"; export * from "./WithdrawFee"; +export * from "./UpgradeLazerContract"; +export * from "./UpdateTrustedSigner"; diff --git a/lazer/contracts/sui/Move.toml b/lazer/contracts/sui/Move.toml index 19ca0072da..2b60658dd6 100644 --- a/lazer/contracts/sui/Move.toml +++ b/lazer/contracts/sui/Move.toml @@ -6,6 +6,16 @@ edition = "2024.beta" [addresses] pyth_lazer = "0x0" +[dependencies.Wormhole] +git = "https://github.com/wormhole-foundation/wormhole.git" +subdir = "sui/wormhole" +# "sui/mainnet" or "sui/testnet" respectively, as "main" doesn't have address +# TODO(matej): It's hard to tell whether these branches are officially +# supported, and testnet one is already outdated, so we either want to have the +# package at MVR at some point, or maybe want to consider creating our own +# branches with symlinked `Move.toml`. +rev = "sui/mainnet" + [dev-dependencies] [dev-addresses] diff --git a/lazer/contracts/sui/README.md b/lazer/contracts/sui/README.md index 7074925d47..ad5cb5caae 100644 --- a/lazer/contracts/sui/README.md +++ b/lazer/contracts/sui/README.md @@ -22,4 +22,9 @@ sui move test test_parse_and_verify_le_ecdsa_update # run a specific test Deploy: +Bump version in [`meta.move`]. **You must do this, otherwise we get locked out +of the package after an upgrade!** + TODO + +[`meta.move`]: sources/meta.move diff --git a/lazer/contracts/sui/sdk/js/.gitignore b/lazer/contracts/sui/sdk/js/.gitignore index 83631f817f..e2b9c63631 100644 --- a/lazer/contracts/sui/sdk/js/.gitignore +++ b/lazer/contracts/sui/sdk/js/.gitignore @@ -1,3 +1,133 @@ -dist/ +.env*.local + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache *.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.* +!.env.example + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Sveltekit cache directory +.svelte-kit/ + +# vitepress build output +**/.vitepress/dist + +# vitepress cache directory +**/.vitepress/cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# Firebase cache directory +.firebase/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v3 +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + +# Vite logs files +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/lazer/contracts/sui/sdk/js/.prettierignore b/lazer/contracts/sui/sdk/js/.prettierignore index 20ecf0de23..f36108ecb9 100644 --- a/lazer/contracts/sui/sdk/js/.prettierignore +++ b/lazer/contracts/sui/sdk/js/.prettierignore @@ -1,9 +1,14 @@ -.turbo/ +.next/ +coverage/ node_modules/ -dist/ +*.tsbuildinfo +.env*.local +.env +.DS_Store dist/ lib/ build/ node_modules/ package.json tsconfig*.json +turbo.json \ No newline at end of file diff --git a/lazer/contracts/sui/sdk/js/examples/fetch-and-verify-update.ts b/lazer/contracts/sui/sdk/js/examples/fetch-and-verify-update.ts deleted file mode 100644 index 7dabeb7b6e..0000000000 --- a/lazer/contracts/sui/sdk/js/examples/fetch-and-verify-update.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { SuiClient } from "@mysten/sui/client"; -import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519"; -import { Transaction } from "@mysten/sui/transactions"; -import { PythLazerClient } from "@pythnetwork/pyth-lazer-sdk"; -import yargs from "yargs"; -import { hideBin } from "yargs/helpers"; - -import { addParseAndVerifyLeEcdsaUpdateCall } from "../src/client.js"; - -async function getOneLeEcdsaUpdate(token: string) { - const lazer = await PythLazerClient.create({ - token, - }); - - const latestPrice = await lazer.getLatestPrice({ - priceFeedIds: [1], - properties: ["price", "bestBidPrice", "bestAskPrice", "exponent"], - formats: ["leEcdsa"], - channel: "fixed_rate@200ms", - jsonBinaryEncoding: "hex", - }); - - return latestPrice; -} - -async function main() { - const args = await yargs(hideBin(process.argv)) - .option("fullnodeUrl", { - type: "string", - description: - "URL of the full Sui node RPC endpoint. e.g: https://fullnode.testnet.sui.io:443", - demandOption: true, - }) - .option("packageId", { - type: "string", - description: "Lazer contract package ID", - demandOption: true, - }) - .option("stateObjectId", { - type: "string", - description: "Lazer contract shared State object ID", - demandOption: true, - }) - .option("lazerUrls", { - type: "array", - string: true, - description: "Lazer WebSocket URLs", - default: [ - "wss://pyth-lazer-0.dourolabs.app/v1/stream", - "wss://pyth-lazer-1.dourolabs.app/v1/stream", - ], - }) - .option("lazerToken", { - type: "string", - description: "Lazer authentication token", - demandOption: true, - }) - .help() - .parseAsync(); - - // Defined as a dependency in turbo.json - // eslint-disable-next-line n/no-process-env - if (process.env.SUI_KEY === undefined) { - throw new Error( - `SUI_KEY environment variable should be set to your Sui private key in hex format.`, - ); - } - - const provider = new SuiClient({ url: args.fullnodeUrl }); - - // Fetch the price update - const update = await getOneLeEcdsaUpdate(args.lazerToken); - - // Build the Sui transaction - const tx = new Transaction(); - - // Add the parse and verify call - addParseAndVerifyLeEcdsaUpdateCall({ - tx, - packageId: args.packageId, - stateObjectId: args.stateObjectId, - updateBytes: Buffer.from(update.leEcdsa?.data ?? "", "hex"), - }); - - // --- You can add more calls to the transaction that consume the parsed update here --- - - const wallet = Ed25519Keypair.fromSecretKey( - // eslint-disable-next-line n/no-process-env - Buffer.from(process.env.SUI_KEY, "hex"), - ); - const res = await provider.signAndExecuteTransaction({ - signer: wallet, - transaction: tx, - options: { showEffects: true, showEvents: true }, - }); - - // eslint-disable-next-line no-console - console.log("Execution result:", JSON.stringify(res, undefined, 2)); -} - -// eslint-disable-next-line unicorn/prefer-top-level-await -main().catch((error: unknown) => { - throw error; -}); diff --git a/lazer/contracts/sui/sdk/js/jest.config.js b/lazer/contracts/sui/sdk/js/jest.config.js new file mode 100644 index 0000000000..9149b2a2bb --- /dev/null +++ b/lazer/contracts/sui/sdk/js/jest.config.js @@ -0,0 +1,3 @@ +import { defineJestConfig } from "@pythnetwork/jest-config/define-config"; + +export default defineJestConfig(); diff --git a/lazer/contracts/sui/sdk/js/package.json b/lazer/contracts/sui/sdk/js/package.json index d85df7edf3..2a3d63dd94 100644 --- a/lazer/contracts/sui/sdk/js/package.json +++ b/lazer/contracts/sui/sdk/js/package.json @@ -1,53 +1,66 @@ { "name": "@pythnetwork/pyth-lazer-sui-js", - "version": "0.2.0", + "version": "0.3.0", "description": "TypeScript SDK for the Pyth Lazer Sui contract", "license": "Apache-2.0", "type": "module", - "engines": { - "node": ">=22.14.0" - }, "files": [ - "dist/**/*" + "dist/**" ], - "exports": { - "./client": { - "require": { - "types": "./dist/cjs/client.d.ts", - "default": "./dist/cjs/client.cjs" - }, - "import": { - "types": "./dist/esm/client.d.ts", - "default": "./dist/esm/client.mjs" - } - }, - "./package.json": "./package.json" + "repository": { + "type": "git", + "url": "https://github.com/pyth-network/pyth-crosschain", + "directory": "lazer/contracts/pyth-lazer-sui-js" + }, + "engines": { + "node": ">=22.14.0" }, "sideEffects": false, "scripts": { - "build": "ts-duality --clean", + "build": "ts-duality --copyOtherFiles", + "clean": "rm -rf ./dist", + "fix:lint": "eslint --fix . --max-warnings 0", "fix:format": "prettier --write .", - "fix:lint": "eslint --fix .", - "test:format": "prettier --check .", "test:lint": "eslint . --max-warnings 0", + "test:format": "prettier --check .", "test:types": "tsc", - "example:fetch-and-verify": "tsx examples/fetch-and-verify-update.ts", - "clean": "rm -rf ./dist" - }, - "dependencies": { - "@mysten/sui": "catalog:", - "@pythnetwork/pyth-lazer-sdk": "workspace:*", - "@types/yargs": "catalog:", - "yargs": "catalog:" + "test:unit": "test-unit", + "example:fetch-and-verify": "tsx src/examples/fetch-and-verify.ts" }, "devDependencies": { "@cprussin/eslint-config": "catalog:", + "@cprussin/prettier-config": "catalog:", "@cprussin/tsconfig": "catalog:", + "@pythnetwork/jest-config": "workspace:", + "@pythnetwork/pyth-lazer-sdk": "workspace:*", + "@types/jest": "catalog:", "@types/node": "catalog:", + "@types/yargs": "catalog:", "eslint": "catalog:", - "prettier": "catalog:" + "jest": "catalog:", + "yargs": "catalog:" + }, + "dependencies": { + "@mysten/sui": "catalog:" }, + "private": false, "publishConfig": { "access": "public" - } + }, + "exports": { + ".": { + "require": { + "types": "./dist/cjs/index.d.ts", + "default": "./dist/cjs/index.cjs" + }, + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.mjs" + } + }, + "./package.json": "./package.json" + }, + "module": "./dist/esm/index.mjs", + "types": "./dist/cjs/index.d.ts", + "main": "./dist/cjs/index.cjs" } diff --git a/lazer/contracts/sui/sdk/js/src/client.ts b/lazer/contracts/sui/sdk/js/src/client.ts deleted file mode 100644 index 908748fd81..0000000000 --- a/lazer/contracts/sui/sdk/js/src/client.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { bcs } from "@mysten/sui/bcs"; -import { Transaction } from "@mysten/sui/transactions"; -import { SUI_CLOCK_OBJECT_ID } from "@mysten/sui/utils"; - -export function addParseAndVerifyLeEcdsaUpdateCall({ - tx, - packageId, - stateObjectId, - updateBytes, -}: { - tx: Transaction; - packageId: string; - stateObjectId: string; - updateBytes: Uint8Array; -}) { - const [updateObj] = tx.moveCall({ - target: `${packageId}::pyth_lazer::parse_and_verify_le_ecdsa_update`, - arguments: [ - tx.object(stateObjectId), - tx.object(SUI_CLOCK_OBJECT_ID), - tx.pure(bcs.vector(bcs.U8).serialize(updateBytes).toBytes()), - ], - }); - return updateObj; -} diff --git a/lazer/contracts/sui/sdk/js/src/examples/fetch-and-verify.ts b/lazer/contracts/sui/sdk/js/src/examples/fetch-and-verify.ts new file mode 100644 index 0000000000..567b63984b --- /dev/null +++ b/lazer/contracts/sui/sdk/js/src/examples/fetch-and-verify.ts @@ -0,0 +1,81 @@ +/* eslint-disable no-console */ +/* eslint-disable @typescript-eslint/no-unused-vars */ + +import process from "node:process"; + +import { SuiClient } from "@mysten/sui/client"; +import { decodeSuiPrivateKey } from "@mysten/sui/cryptography"; +import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519"; +import { Transaction } from "@mysten/sui/transactions"; +import { PythLazerClient } from "@pythnetwork/pyth-lazer-sdk"; +import yargs from "yargs"; +import { hideBin } from "yargs/helpers"; + +import { addParseAndVerifyLeEcdsaUpdateCall } from "../index.js"; + +const { fullnodeUrl, stateId, lazerToken } = await yargs(hideBin(process.argv)) + .option("fullnode-url", { + type: "string", + description: + "URL of the full Sui node RPC endpoint. e.g: https://fullnode.testnet.sui.io:443", + demandOption: true, + }) + .option("state-id", { + type: "string", + description: "Lazer contract shared State object ID", + demandOption: true, + }) + .option("lazer-token", { + type: "string", + description: "Lazer authentication token", + demandOption: true, + }) + .help() + .parseAsync(); + +// eslint-disable-next-line n/no-process-env +if (!process.env.SUI_KEY) { + throw new Error( + "'SUI_KEY' environment variable should be set to your Sui private key in Bech32 format.", + ); +} +// eslint-disable-next-line n/no-process-env +const keypair = decodeSuiPrivateKey(process.env.SUI_KEY); +const signer = Ed25519Keypair.fromSecretKey(keypair.secretKey); + +///////////////////////////////////////////////////////////////////////////////////////// + +// Steps for fetching and verifying the price update: + +// 1. Fetch the price update from Pyth Lazer in "leEcdsa" format: +const lazer = await PythLazerClient.create({ token: lazerToken }); +const latestPrice = await lazer.getLatestPrice({ + priceFeedIds: [1], + properties: ["price", "bestBidPrice", "bestAskPrice", "exponent"], + formats: ["leEcdsa"], + channel: "fixed_rate@200ms", + jsonBinaryEncoding: "hex", +}); +const update = Buffer.from(latestPrice.leEcdsa?.data ?? "", "hex"); + +// 2. Create a new Sui transaction: +const client = new SuiClient({ url: fullnodeUrl }); +const tx = new Transaction(); + +// 3. Add the parse and verify call: +const verifiedUpdate = await addParseAndVerifyLeEcdsaUpdateCall({ + client, + tx, + stateObjectId: stateId, + update, +}); + +// 4. Consume `verifiedUpdate` in your own contract with additional calls... + +// 5. Sign and execute the transaction: +const result = await client.signAndExecuteTransaction({ + transaction: tx, + signer, +}); + +console.log("Result:", JSON.stringify(result, undefined, 2)); diff --git a/lazer/contracts/sui/sdk/js/src/index.ts b/lazer/contracts/sui/sdk/js/src/index.ts new file mode 100644 index 0000000000..48954e30cc --- /dev/null +++ b/lazer/contracts/sui/sdk/js/src/index.ts @@ -0,0 +1,67 @@ +import type { MoveValue, SuiClient } from "@mysten/sui/client"; +import { Transaction } from "@mysten/sui/transactions"; + +export async function addParseAndVerifyLeEcdsaUpdateCall(opts: { + client: SuiClient; + tx: Transaction; + stateObjectId: string; + update: Uint8Array; +}) { + const { client, tx, stateObjectId, update } = opts; + const latestPackageId = await getLatestPackageId(client, stateObjectId); + return tx.moveCall({ + target: `${latestPackageId}::pyth_lazer::parse_and_verify_le_ecdsa_update`, + arguments: [ + tx.object(stateObjectId), + tx.object.clock(), + tx.pure.vector("u8", update), + ], + }); +} + +async function getLatestPackageId( + client: SuiClient, + stateObjectId: string, +): Promise { + const { data: stateObject, error } = await client.getObject({ + id: stateObjectId, + options: { showContent: true }, + }); + if (!stateObject?.content || error) { + throw new Error( + `Failed to get Sui Lazer State: ${error?.code ?? "undefined"}`, + ); + } + if (stateObject.content.dataType !== "moveObject") { + throw new Error( + `Sui Lazer State must be an object, got: ${stateObject.content.dataType}`, + ); + } + + const state = stateObject.content; + if (!hasStructField(state, "upgrade_cap")) { + throw new Error("Missing 'upgrade_cap' in Sui Lazer State"); + } + const upgradeCap = state.fields.upgrade_cap; + if ( + !hasStructField(upgradeCap, "package") || + typeof upgradeCap.fields.package !== "string" + ) { + throw new Error("Could not find 'package' string in Sui Lazer UpgradeCap"); + } + return upgradeCap.fields.package; +} + +function hasStructField( + value: MoveValue, + name: F, +): value is { fields: Record } { + return hasProperty(value, "fields") && hasProperty(value.fields, name); +} + +function hasProperty( + value: unknown, + name: P, +): value is Record { + return typeof value === "object" && !!value && name in value; +} diff --git a/lazer/contracts/sui/sdk/js/tsconfig.build.json b/lazer/contracts/sui/sdk/js/tsconfig.build.json index ce7ef2c031..adfac17607 100644 --- a/lazer/contracts/sui/sdk/js/tsconfig.build.json +++ b/lazer/contracts/sui/sdk/js/tsconfig.build.json @@ -6,5 +6,10 @@ "declaration": true, "isolatedModules": false }, - "exclude": ["node_modules", "dist", "examples/", "**/__tests__/*"] + "exclude": [ + "node_modules", + "dist", + "src/examples/", + "**/__tests__/*" + ] } diff --git a/lazer/contracts/sui/sdk/js/tsconfig.json b/lazer/contracts/sui/sdk/js/tsconfig.json index 7f2620c46e..3981b0c4dd 100644 --- a/lazer/contracts/sui/sdk/js/tsconfig.json +++ b/lazer/contracts/sui/sdk/js/tsconfig.json @@ -1,4 +1,8 @@ { "extends": "@cprussin/tsconfig/base.json", - "exclude": ["dist", "node_modules", "**/__tests__/*"] + "include": ["src"], + "compilerOptions": { + "lib": ["ESNext"] + }, + "exclude": ["node_modules"] } diff --git a/lazer/contracts/sui/sources/actions.move b/lazer/contracts/sui/sources/actions.move new file mode 100644 index 0000000000..5bb6c0aaef --- /dev/null +++ b/lazer/contracts/sui/sources/actions.move @@ -0,0 +1,58 @@ +/// Governance actions of the Lazer contract. +module pyth_lazer::actions; + +use sui::package::{UpgradeCap, UpgradeReceipt, UpgradeTicket}; + +use wormhole::{bytes32, external_address, vaa::VAA}; + +use pyth_lazer::{ + governance, + state::{Self, secp256k1_compressed_pubkey_len, State}, +}; + +#[error] +const ENotUpgradeLazerContract: vector = "Expected UpgradeLazerContract message"; +#[error] +const ENotUpdateTrustedSigner: vector = "Expected UpdateTrustedSigner message"; + +/// Entrypoint for initializing the Lazer contract. +entry fun init_lazer( + upgrade_cap: UpgradeCap, + emitter_chain_id: u16, + emitter_address: vector, + ctx: &mut TxContext +) { + let governance = governance::new( + emitter_chain_id, + external_address::new_nonzero(bytes32::new(emitter_address)) + ); + state::share(upgrade_cap, governance, ctx); +} + +// Reference: `UpgradeLazerContract256Bit` +public fun upgrade(state: &mut State, vaa: VAA): UpgradeTicket { + let current_cap = state.current_cap(); + let (header, mut parser) = state.unwrap_ptgm(¤t_cap, vaa); + assert!(header.is_upgrade_lazer_contract(), ENotUpgradeLazerContract); + + let digest = parser.take_bytes(32); + parser.destroy_empty(); + state.authorize_upgrade(¤t_cap, digest) +} + +public fun commit_upgrade(state: &mut State, receipt: UpgradeReceipt) { + let current_cap = state.current_cap(); + state.commit_upgrade(¤t_cap, receipt) +} + +// Reference: `UpdateTrustedSigner264Bit` +public fun update_trusted_signer(state: &mut State, vaa: VAA) { + let current_cap = state.current_cap(); + let (header, mut parser) = state.unwrap_ptgm(¤t_cap, vaa); + assert!(header.is_update_trusted_signer(), ENotUpdateTrustedSigner); + + let public_key = parser.take_bytes(secp256k1_compressed_pubkey_len()); + let expires_at = parser.take_u64_be(); + parser.destroy_empty(); + state.update_trusted_signer(¤t_cap, public_key, expires_at); +} diff --git a/lazer/contracts/sui/sources/admin.move b/lazer/contracts/sui/sources/admin.move deleted file mode 100644 index 4daa3197de..0000000000 --- a/lazer/contracts/sui/sources/admin.move +++ /dev/null @@ -1,29 +0,0 @@ -module pyth_lazer::admin; - -public struct AdminCap has key, store { - id: UID, -} - -/// The `ADMIN` resource serves as the one-time witness. -/// It has the `drop` ability, allowing it to be consumed immediately after use. -/// See: https://move-book.com/programmability/one-time-witness -public struct ADMIN has drop {} - -/// Initializes the module. Called at publish time. -/// Creates and transfers ownership of the singular AdminCap capability to the deployer. -/// Only the AdminCap owner can update the trusted signers. -fun init(_: ADMIN, ctx: &mut TxContext) { - let cap = AdminCap { id: object::new(ctx) }; - transfer::public_transfer(cap, tx_context::sender(ctx)); -} - -#[test_only] -public fun mint_for_test(ctx: &mut TxContext): AdminCap { - AdminCap { id: object::new(ctx) } -} - -#[test_only] -public fun destroy_for_test(cap: AdminCap) { - let AdminCap { id } = cap; - object::delete(id) -} diff --git a/lazer/contracts/sui/sources/channel.move b/lazer/contracts/sui/sources/channel.move index 0f5e4ca50b..76ed1a69e4 100644 --- a/lazer/contracts/sui/sources/channel.move +++ b/lazer/contracts/sui/sources/channel.move @@ -1,7 +1,7 @@ module pyth_lazer::channel; -// Error codes for channel parsing -const EInvalidChannel: u64 = 1; +#[error] +const EInvalidChannel: vector = "Invalid channel value"; public enum Channel has copy, drop { RealTime, diff --git a/lazer/contracts/sui/sources/feed.move b/lazer/contracts/sui/sources/feed.move index a937e62f4b..b9ff9a34bc 100644 --- a/lazer/contracts/sui/sources/feed.move +++ b/lazer/contracts/sui/sources/feed.move @@ -4,8 +4,8 @@ use pyth_lazer::i16::{Self, I16}; use pyth_lazer::i64::{Self, I64}; use sui::bcs; -// Error codes for feed parsing -const EInvalidProperty: u64 = 2; +#[error] +const EInvalidProperty: vector = "Invalid property ID"; /// The feed struct is based on the Lazer rust protocol definition defined here: /// https://github.com/pyth-network/pyth-crosschain/blob/main/lazer/sdk/rust/protocol/src/payload.rs diff --git a/lazer/contracts/sui/sources/governance.move b/lazer/contracts/sui/sources/governance.move new file mode 100644 index 0000000000..ba2f3af2f3 --- /dev/null +++ b/lazer/contracts/sui/sources/governance.move @@ -0,0 +1,93 @@ +/// Types and functions for processing governance messages. +module pyth_lazer::governance; + +use wormhole::external_address::ExternalAddress; + +use pyth_lazer::parser::Parser; + +/// Reference: +/// https://github.com/pyth-network/pyth-crosschain/blob/b021cfe9b2716947f22d1724cd3fa7e3de6b026e/governance/remote_executor/programs/remote-executor/src/state/governance_payload.rs#L81 +const MAGIC: vector = "PTGM"; + +/// Governace message module. Always 3, as this contract uses "Lazer" actions. +const MODULE: u8 = 3; + +/// Reference: +/// https://wormhole.com/docs/products/reference/chain-ids/#__tabbed_1_1 +/// https://github.com/pyth-network/pyth-crosschain/blob/b021cfe9b2716947f22d1724cd3fa7e3de6b026e/governance/xc_admin/packages/xc_admin_common/src/chains.ts#L274 +const RECEIVER_CHAIN_ID: u16 = 21; + +#[error] +const EMismatchedMagic: vector = "Mismatched governance header magic number, should be \"PTGM\""; +#[error] +const EMismatchedModule: vector = "Mismatched governance header module number, should be 2"; +#[error] +const EMismatchedEmitterChainID: vector = "Mismatched governance emitter chain ID"; +#[error] +const EMismatchedReceiverChainID: vector = "Mismatched governance receiver chain ID"; +#[error] +const EMismatchedAddress: vector = "Mismatched governance emitter address"; +#[error] +const EOldSequenceNumber: vector = "Incoming sequence number older than previously seen"; + +/// State used to track and validate governance messages coming as VAAs. +public struct Governance has copy, drop, store { + chain_id: u16, + address: ExternalAddress, + seen_sequence: u64, +} + +public(package) fun new(chain_id: u16, address: ExternalAddress): Governance { + Governance { + chain_id, + address, + seen_sequence: 0, + } +} + +/// Process incoming VAA message parameters, asserting that the message is safe +/// to process further. +public(package) fun process_incoming( + self: &mut Governance, + chain_id: u16, + address: ExternalAddress, + sequence: u64 +) { + assert!(self.chain_id == chain_id, EMismatchedEmitterChainID); + assert!(self.address == address, EMismatchedAddress); + // TODO: See https://wormhole.com/docs/protocol/infrastructure/vaas/#verified-action-approvals + // - is this enough to avoid replay attacks? + assert!(self.seen_sequence < sequence, EOldSequenceNumber); + self.seen_sequence = sequence; +} + +/// Reference: +/// https://github.com/pyth-network/pyth-crosschain/blob/b021cfe9b2716947f22d1724cd3fa7e3de6b026e/governance/remote_executor/programs/remote-executor/src/state/governance_payload.rs#L86 +public struct GovernanceHeader has drop { + action: u8, +} + +// Governance action "enum" implemented as a collection of package-private +// predicates to allow modification in the future, as Sui types cannot be +// private (yet?): + +public(package) fun is_upgrade_lazer_contract(self: &GovernanceHeader): bool { + self.action == 0 +} + +public(package) fun is_update_trusted_signer(self: &GovernanceHeader): bool { + self.action == 1 +} + +/// Reference: +/// https://github.com/pyth-network/pyth-crosschain/blob/b021cfe9b2716947f22d1724cd3fa7e3de6b026e/governance/xc_admin/packages/xc_admin_common/src/governance_payload/PythGovernanceAction.ts#L86 +public(package) fun parse_header(parser: &mut Parser): GovernanceHeader { + let magic = parser.take_bytes(4); + assert!(magic == MAGIC, EMismatchedMagic); + let module_ = parser.take_u8(); + assert!(module_ == MODULE, EMismatchedModule); + let action = parser.take_u8(); + let chain = parser.take_u16_be(); + assert!(chain == RECEIVER_CHAIN_ID, EMismatchedReceiverChainID); + GovernanceHeader { action } +} diff --git a/lazer/contracts/sui/sources/i16.move b/lazer/contracts/sui/sources/i16.move index 0fbe5850a4..d22a305bea 100644 --- a/lazer/contracts/sui/sources/i16.move +++ b/lazer/contracts/sui/sources/i16.move @@ -1,5 +1,4 @@ /// Adapted from pyth::i64, modified for i16 - module pyth_lazer::i16; const MAX_POSITIVE_MAGNITUDE: u64 = (1 << 15) - 1; // 32767 diff --git a/lazer/contracts/sui/sources/i64.move b/lazer/contracts/sui/sources/i64.move index 863527c2ce..80f2f9d072 100644 --- a/lazer/contracts/sui/sources/i64.move +++ b/lazer/contracts/sui/sources/i64.move @@ -1,5 +1,4 @@ /// Adapted from pyth::i64 - module pyth_lazer::i64; const MAX_POSITIVE_MAGNITUDE: u64 = (1 << 63) - 1; diff --git a/lazer/contracts/sui/sources/meta.move b/lazer/contracts/sui/sources/meta.move new file mode 100644 index 0000000000..b896641743 --- /dev/null +++ b/lazer/contracts/sui/sources/meta.move @@ -0,0 +1,11 @@ +module pyth_lazer::meta; + +/// Version of this package. As Sui packages do not have access to any API that +/// would give them their current address or version, we track it manually here. +/// +/// WARNING: Construction of `CurrentCap` requires this version to match the +/// `UpgradeCap` version, and thus attempts to publish or upgrade the package +/// using an invalid version will result in the package becoming locked. +public(package) fun version(): u64 { + 1 +} diff --git a/lazer/contracts/sui/sources/parser.move b/lazer/contracts/sui/sources/parser.move new file mode 100644 index 0000000000..da4ac9f754 --- /dev/null +++ b/lazer/contracts/sui/sources/parser.move @@ -0,0 +1,36 @@ +/// Convenience wrapper around `wormhole::{bytes, cursor}` utils. +module pyth_lazer::parser; + +use wormhole::{bytes, cursor::{Self, Cursor}}; + +public struct Parser(Cursor) + +public(package) fun new(bytes: vector): Parser { + Parser(cursor::new(bytes)) +} + +public(package) fun take_u8(self: &mut Parser): u8 { + bytes::take_u8(&mut self.0) +} + +public(package) fun take_u16_be(self: &mut Parser): u16 { + bytes::take_u16_be(&mut self.0) +} + +public(package) fun take_u64_be(self: &mut Parser): u64 { + bytes::take_u64_be(&mut self.0) +} + +public(package) fun take_bytes(self: &mut Parser, num: u64): vector { + bytes::take_bytes(&mut self.0, num) +} + +public(package) fun take_rest(self: Parser): vector { + let Parser(cursor) = self; + cursor.take_rest() +} + +public(package) fun destroy_empty(self: Parser) { + let Parser(cursor) = self; + cursor.destroy_empty() +} diff --git a/lazer/contracts/sui/sources/pyth_lazer.move b/lazer/contracts/sui/sources/pyth_lazer.move index 4d23b8341c..359da28f38 100644 --- a/lazer/contracts/sui/sources/pyth_lazer.move +++ b/lazer/contracts/sui/sources/pyth_lazer.move @@ -1,33 +1,30 @@ module pyth_lazer::pyth_lazer; -use pyth_lazer::state::{Self, State}; -use pyth_lazer::update::{Self, Update}; -use sui::bcs; -use sui::clock::Clock; -use sui::ecdsa_k1::secp256k1_ecrecover; +use sui::{ + bcs, + clock::Clock, + ecdsa_k1::secp256k1_ecrecover, +}; + +use pyth_lazer::{ + state::State, + update::{Self, Update}, +}; const SECP256K1_SIG_LEN: u32 = 65; const UPDATE_MESSAGE_MAGIC: u32 = 1296547300; const PAYLOAD_MAGIC: u32 = 2479346549; -// Error codes -const ESignerNotTrusted: u64 = 2; -const ESignerExpired: u64 = 3; -const EInvalidMagic: u64 = 4; -const EInvalidPayloadLength: u64 = 6; - -/// The `PYTH_LAZER` resource serves as the one-time witness. -/// It has the `drop` ability, allowing it to be consumed immediately after use. -/// See: https://move-book.com/programmability/one-time-witness -public struct PYTH_LAZER has drop {} - -/// Initializes the module. Called at publish time. -/// Creates and shares the singular State object. -/// AdminCap is created and transferred in admin::init via a One-Time Witness. -fun init(_: PYTH_LAZER, ctx: &mut TxContext) { - let s = state::new(ctx); - transfer::public_share_object(s); -} +#[error] +const ESignerNotTrusted: vector = "Recovered public key is not in the trusted signers list"; +#[error] +const ESignerExpired: vector = "Signer's certificate has expired"; +#[error] +const EInvalidUpdateMagic: vector = "Invalid magic number in update"; +#[error] +const EInvalidPayloadMagic: vector = "Invalid magic number in payload"; +#[error] +const EInvalidPayloadLength: vector = "Payload length doesn't match data"; /// Verify LE ECDSA message signature against trusted signers. /// @@ -45,26 +42,26 @@ fun init(_: PYTH_LAZER, ctx: &mut TxContext) { /// * `ESignerNotTrusted` - The recovered public key is not in the trusted signers list /// * `ESignerExpired` - The signer's certificate has expired public(package) fun verify_le_ecdsa_message( - s: &State, + state: &State, clock: &Clock, signature: &vector, payload: &vector, ) { + let current_cap = state.current_cap(); + // 0 stands for keccak256 hash let pubkey = secp256k1_ecrecover(signature, payload, 0); // Check if the recovered pubkey is in the trusted signers list - let trusted_signers = s.get_trusted_signers(); - let mut maybe_idx = state::find_signer_index(trusted_signers, &pubkey); - - if (option::is_some(&maybe_idx)) { - let idx = option::extract(&mut maybe_idx); - let found_signer = &trusted_signers[idx]; - let expires_at_ms = found_signer.expires_at_ms(); - assert!(clock.timestamp_ms() < expires_at_ms, ESignerExpired); - } else { - abort ESignerNotTrusted - } + let trusted_signers = state.trusted_signers(¤t_cap); + let mut maybe_idx = trusted_signers.find_index!(|signer| + signer.public_key() == &pubkey + ); + + assert!(maybe_idx.is_some(), ESignerNotTrusted); + let idx = maybe_idx.extract(); + let expires_at_ms = trusted_signers[idx].expires_at_ms(); + assert!(clock.timestamp_ms() < expires_at_ms, ESignerExpired); } /// Parse the Lazer update message and validate the signature within. @@ -77,7 +74,8 @@ public(package) fun verify_le_ecdsa_message( /// * `update` - The LeEcdsa formatted Lazer update /// /// # Errors -/// * `EInvalidMagic` - Invalid magic number in update or payload +/// * `EInvalidUpdateMagic` - Invalid magic number in update +/// * `EInvalidPayloadMagic` - Invalid magic number in payload /// * `EInvalidPayloadLength` - Payload length doesn't match actual data /// * `ESignerNotTrusted` - The recovered public key is not in the trusted signers list /// * `ESignerExpired` - The signer's certificate has expired @@ -86,7 +84,7 @@ public fun parse_and_verify_le_ecdsa_update(s: &State, clock: &Clock, update: ve // Parse and validate message magic let magic = cursor.peel_u32(); - assert!(magic == UPDATE_MESSAGE_MAGIC, EInvalidMagic); + assert!(magic == UPDATE_MESSAGE_MAGIC, EInvalidUpdateMagic); // Parse signature let mut signature = vector::empty(); @@ -106,7 +104,7 @@ public fun parse_and_verify_le_ecdsa_update(s: &State, clock: &Clock, update: ve // Parse payload let mut payload_cursor = bcs::new(payload); let payload_magic = payload_cursor.peel_u32(); - assert!(payload_magic == PAYLOAD_MAGIC, EInvalidMagic); + assert!(payload_magic == PAYLOAD_MAGIC, EInvalidPayloadMagic); // Verify the signature against trusted signers verify_le_ecdsa_message(s, clock, &signature, &payload); diff --git a/lazer/contracts/sui/sources/state.move b/lazer/contracts/sui/sources/state.move index bfa08967ea..dc90a6d4b0 100644 --- a/lazer/contracts/sui/sources/state.move +++ b/lazer/contracts/sui/sources/state.move @@ -1,98 +1,178 @@ module pyth_lazer::state; #[test_only] -use pyth_lazer::admin; -use pyth_lazer::admin::AdminCap; +use std::unit_test::{assert_eq, destroy}; +use std::type_name; +#[test_only] +use sui::package; +use sui::package::{UpgradeCap, UpgradeReceipt, UpgradeTicket}; -const SECP256K1_COMPRESSED_PUBKEY_LEN: u64 = 33; -const EInvalidPubkeyLen: u64 = 1; -const ESignerNotFound: u64 = 2; +#[test_only] +use wormhole::external_address; +use wormhole::vaa::VAA; + +use pyth_lazer::{ + governance::{Self, Governance, GovernanceHeader}, + meta, + parser::{Self, Parser}, +}; + +#[error] +const EInvalidPubkeyLen: vector = "Invalid public key length, must be 33"; +#[error] +const ERemovedSignerNotFound: vector = "Could not remove non-existent trusted signer"; +#[error] +const EDifferentVersion: vector = "State can only be used with the current package version"; +#[error] +const EDifferentUpgradeCap: vector = "Supplied UpgradeCap belongs to a different package"; + +// Constant as a function to allow exporting +public(package) fun secp256k1_compressed_pubkey_len(): u64 { + 33 +} /// Lazer State consists of the current set of trusted signers. /// By verifying that a price update was signed by one of these public keys, /// you can validate the authenticity of a Lazer price update. /// /// The trusted signers are subject to rotations and expiry. -public struct State has key, store { +/// +/// Always use the latest version of the Sui Lazer contract, as older versions +/// will fail when accessing the state. Official SDK fetches the current +/// version automatically. +public struct State has key { id: UID, trusted_signers: vector, + upgrade_cap: UpgradeCap, + governance: Governance, } -/// A trusted signer is comprised of a pubkey and an expiry timestamp (seconds since Unix epoch). -/// A signer's signature should only be trusted up to timestamp `expires_at`. -public struct TrustedSignerInfo has copy, drop, store { - public_key: vector, - expires_at: u64, +/// Construct and share a unique Lazer State, taking ownership of the supplied +/// UpgradeCap. +public(package) fun share( + upgrade_cap: UpgradeCap, + governance: Governance, + ctx: &mut TxContext +) { + assert!( + upgrade_cap.package().to_address() == type_name::original_id(), + EDifferentUpgradeCap, + ); + assert!(meta::version() == upgrade_cap.version(), EDifferentVersion); + transfer::share_object(State { + id: object::new(ctx), + trusted_signers: vector[], + upgrade_cap, + governance, + }) } -public(package) fun new(ctx: &mut TxContext): State { - State { - id: object::new(ctx), - trusted_signers: vector::empty(), - } +/// Unpack Pyth Governance message wrapped in VAA, while checking its validity. +public(package) fun unwrap_ptgm( + self: &mut State, + _: &CurrentCap, + vaa: VAA +): (GovernanceHeader, Parser) { + let sequence = vaa.sequence(); + let (chain, address, payload) = vaa.take_emitter_info_and_payload(); + self.governance.process_incoming(chain, address, sequence); + let mut parser = parser::new(payload); + let header = governance::parse_header(&mut parser); + (header, parser) } -/// Get the trusted signer's public key -public fun public_key(info: &TrustedSignerInfo): &vector { - &info.public_key +public(package) fun authorize_upgrade( + self: &mut State, + _: &CurrentCap, + digest: vector, +): UpgradeTicket { + let policy = self.upgrade_cap.policy(); + self.upgrade_cap.authorize(policy, digest) } -/// Get the trusted signer's expiry timestamp, converted to milliseconds -public fun expires_at_ms(info: &TrustedSignerInfo): u64 { - info.expires_at * 1000 +public(package) fun commit_upgrade( + self: &mut State, + _: &CurrentCap, + receipt: UpgradeReceipt, +) { + self.upgrade_cap.commit(receipt) } -/// Get the list of trusted signers -public fun get_trusted_signers(s: &State): &vector { - &s.trusted_signers +public(package) fun trusted_signers( + self: &State, + _: &CurrentCap +): &vector { + &self.trusted_signers } -/// Upsert a trusted signer's information or remove them. Can only be called by the AdminCap holder. +/// Upsert a trusted signer's information or remove them. /// - If the trusted signer pubkey already exists, the expires_at will be updated. /// - If the expired_at is set to zero, the trusted signer will be removed. /// - If the pubkey isn't found, it is added as a new trusted signer with the given expires_at. -public fun update_trusted_signer(_: &AdminCap, s: &mut State, pubkey: vector, expires_at: u64) { - assert!(pubkey.length() == SECP256K1_COMPRESSED_PUBKEY_LEN, EInvalidPubkeyLen); +public(package) fun update_trusted_signer( + self: &mut State, + _: &CurrentCap, + pubkey: vector, + expires_at: u64 +) { + assert!( + pubkey.length() == secp256k1_compressed_pubkey_len(), + EInvalidPubkeyLen + ); - let mut maybe_idx = find_signer_index(&s.trusted_signers, &pubkey); + let mut maybe_idx = self.trusted_signers.find_index!(|signer| + signer.public_key() == &pubkey + ); if (expires_at == 0) { if (maybe_idx.is_some()) { let idx = maybe_idx.extract(); - // Remove by swapping with last (order not preserved), discard removed value - let _ = s.trusted_signers.swap_remove(idx); + // Remove by swapping with last (order not preserved), discard + // removed value + self.trusted_signers.swap_remove(idx); } else { maybe_idx.destroy_none(); - abort ESignerNotFound + abort ERemovedSignerNotFound }; return }; if (maybe_idx.is_some()) { let idx = maybe_idx.extract(); - let info_ref = &mut s.trusted_signers[idx]; + let info_ref = &mut self.trusted_signers[idx]; info_ref.expires_at = expires_at } else { maybe_idx.destroy_none(); - s.trusted_signers.push_back( + self.trusted_signers.push_back( TrustedSignerInfo { public_key: pubkey, expires_at } ); } } -public fun find_signer_index( - signers: &vector, - public_key: &vector, -): Option { - let len = signers.length(); - let mut i: u64 = 0; - while (i < len) { - let signer = &signers[i]; - if (signer.public_key() == public_key) { - return option::some(i) - }; - i = i + 1 - }; - option::none() +/// Capability asserting that an owner has checked the current package version +/// against the used one. +public struct CurrentCap has drop {} + +public(package) fun current_cap(self: &State): CurrentCap { + assert!(meta::version() == self.upgrade_cap.version(), EDifferentVersion); + CurrentCap {} +} + +/// A trusted signer is comprised of a pubkey and an expiry timestamp (seconds +/// since Unix epoch). A signer's signature should only be trusted up to +/// timestamp `expires_at`. +public struct TrustedSignerInfo has copy, drop, store { + public_key: vector, + expires_at: u64, +} + +/// Get a reference to the trusted signer's public key. +public(package) fun public_key(info: &TrustedSignerInfo): &vector { + &info.public_key +} + +/// Get the trusted signer's expiry timestamp, converted to milliseconds. +public(package) fun expires_at_ms(info: &TrustedSignerInfo): u64 { + info.expires_at * 1000 } #[test_only] @@ -100,119 +180,102 @@ public fun new_for_test(ctx: &mut TxContext): State { State { id: object::new(ctx), trusted_signers: vector::empty(), + upgrade_cap: package::test_publish( + object::id_from_address(@0), + ctx + ), + governance: governance::new(0, external_address::default()) } } -#[test_only] -public fun destroy_for_test(s: State) { - let State { id, trusted_signers } = s; - let _ = trusted_signers; - id.delete(); -} - #[test] public fun test_add_new_signer() { let mut ctx = tx_context::dummy(); - let mut s = new_for_test(&mut ctx); - let admin_cap = admin::mint_for_test(&mut ctx); + let mut state = new_for_test(&mut ctx); + let current_cap = state.current_cap(); let pk = x"030102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"; - let expiry: u64 = 123; - - update_trusted_signer(&admin_cap, &mut s, pk, expiry); + let expiry = 123u64; + state.update_trusted_signer(¤t_cap, pk, expiry); - let signers_ref = s.get_trusted_signers(); - assert!(signers_ref.length() == 1, 100); - let info = &signers_ref[0]; - assert!(info.expires_at == 123, 101); - let got_pk = info.public_key(); - assert!(got_pk.length() == SECP256K1_COMPRESSED_PUBKEY_LEN, 102); + let signers = state.trusted_signers(¤t_cap); + assert_eq!(signers.length(), 1); + let signer = &signers[0]; + assert_eq!(signer.expires_at, expiry); + assert_eq!(signer.public_key, pk); - s.destroy_for_test(); - admin_cap.destroy_for_test(); + destroy(state); } #[test] public fun test_update_existing_signer_expiry() { let mut ctx = tx_context::dummy(); - let mut s = new_for_test(&mut ctx); - let admin_cap = admin::mint_for_test(&mut ctx); + let mut state = new_for_test(&mut ctx); + let current_cap = state.current_cap(); - update_trusted_signer( - &admin_cap, - &mut s, + state.update_trusted_signer( + ¤t_cap, x"032a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a", 1000, ); - update_trusted_signer( - &admin_cap, - &mut s, + state.update_trusted_signer( + ¤t_cap, x"032a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a", 2000, ); - let signers_ref = s.get_trusted_signers(); - assert!(signers_ref.length() == 1, 110); - let info = &signers_ref[0]; - assert!(info.expires_at == 2000, 111); + let signers = state.trusted_signers(¤t_cap); + assert_eq!(signers.length(), 1); + assert_eq!(signers[0].expires_at, 2000); - s.destroy_for_test(); - admin_cap.destroy_for_test(); + destroy(state); } #[test] public fun test_remove_signer_by_zero_expiry() { let mut ctx = tx_context::dummy(); - let mut s = new_for_test(&mut ctx); - let admin_cap = admin::mint_for_test(&mut ctx); + let mut state = new_for_test(&mut ctx); + let current_cap = state.current_cap(); - update_trusted_signer( - &admin_cap, - &mut s, + state.update_trusted_signer( + ¤t_cap, x"030707070707070707070707070707070707070707070707070707070707070707", 999, ); - update_trusted_signer( - &admin_cap, - &mut s, + state.update_trusted_signer( + ¤t_cap, x"030707070707070707070707070707070707070707070707070707070707070707", 0, ); - let signers_ref = s.get_trusted_signers(); - assert!(signers_ref.length() == 0, 120); + assert_eq!(state.trusted_signers(¤t_cap).length(), 0); - s.destroy_for_test(); - admin_cap.destroy_for_test(); + destroy(state); } #[test, expected_failure(abort_code = EInvalidPubkeyLen)] public fun test_invalid_pubkey_length_rejected() { let mut ctx = tx_context::dummy(); - let mut s = new_for_test(&mut ctx); - let admin_cap = admin::mint_for_test(&mut ctx); + let mut state = new_for_test(&mut ctx); + let current_cap = state.current_cap(); - let short_pk = x"010203"; - update_trusted_signer(&admin_cap, &mut s, short_pk, 1); + state.update_trusted_signer(¤t_cap, x"010203", 1); - s.destroy_for_test(); - admin_cap.destroy_for_test(); + destroy(state); } -#[test, expected_failure(abort_code = ESignerNotFound)] +#[test, expected_failure(abort_code = ERemovedSignerNotFound)] public fun test_remove_nonexistent_signer_fails() { let mut ctx = tx_context::dummy(); - let mut s = new_for_test(&mut ctx); - let admin_cap = admin::mint_for_test(&mut ctx); + let mut state = new_for_test(&mut ctx); + let current_cap = state.current_cap(); // Try to remove a signer that doesn't exist by setting expires_at to 0 - update_trusted_signer( - &admin_cap, - &mut s, + state.update_trusted_signer( + ¤t_cap, x"03aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 0, ); - s.destroy_for_test(); - admin_cap.destroy_for_test(); + destroy(state); } diff --git a/lazer/contracts/sui/tests/pyth_lazer_tests.move b/lazer/contracts/sui/tests/pyth_lazer_tests.move index 74dd8e2773..192fd5fd1c 100644 --- a/lazer/contracts/sui/tests/pyth_lazer_tests.move +++ b/lazer/contracts/sui/tests/pyth_lazer_tests.move @@ -2,14 +2,21 @@ #[allow(implicit_const_copy)] module pyth_lazer::pyth_lazer_tests; -use pyth_lazer::admin; -use pyth_lazer::channel::new_fixed_rate_200ms; -use pyth_lazer::i16; -use pyth_lazer::i64; -use pyth_lazer::pyth_lazer::{parse_and_verify_le_ecdsa_update, verify_le_ecdsa_message, ESignerNotTrusted, ESignerExpired, EInvalidMagic, EInvalidPayloadLength}; -use pyth_lazer::state; +use std::unit_test::{assert_eq, destroy}; use sui::clock; +use pyth_lazer::{ + channel::new_fixed_rate_200ms, + i16, + i64, + pyth_lazer::{ + parse_and_verify_le_ecdsa_update, verify_le_ecdsa_message, + ESignerNotTrusted, ESignerExpired, EInvalidUpdateMagic, + EInvalidPayloadMagic, EInvalidPayloadLength + }, + state, +}; + const TEST_LAZER_UPDATE: vector = x"e4bd474d42e3c9c3477b30f2c5527ebe2fb2c8adadadacaddfa7d95243b80fb8f0d813b453e587f140cf40a1120d75f1ffee8ad4337267e4fcbd23eabb2a555804f85ec101a10075d3c793c0f4295fbb3c060003030100000007005986bacb520a00000162e937ca520a000002a5087bd4520a000004f8ff06000700080002000000070078625c456100000001aba11b456100000002ba8ac0456100000004f8ff060007000800700000000700d8c3e1445a1c940101000000000000000002000000000000000004f4ff0601f03ee30100000000070100e0c6f2b93c0600080100209db406000000"; const TEST_PAYLOAD: vector = x"75d3c793c0f4295fbb3c060003030100000007005986bacb520a00000162e937ca520a000002a5087bd4520a000004f8ff06000700080002000000070078625c456100000001aba11b456100000002ba8ac0456100000004f8ff060007000800700000000700d8c3e1445a1c940101000000000000000002000000000000000004f4ff0601f03ee30100000000070100e0c6f2b93c0600080100209db406000000"; const TEST_SIGNATURE: vector = x"42e3c9c3477b30f2c5527ebe2fb2c8adadadacaddfa7d95243b80fb8f0d813b453e587f140cf40a1120d75f1ffee8ad4337267e4fcbd23eabb2a555804f85ec101"; @@ -60,198 +67,195 @@ The test data above is from the Lazer subscription: #[test] public fun test_parse_and_verify_le_ecdsa_update() { let mut ctx = tx_context::dummy(); - let mut s = state::new_for_test(&mut ctx); - let admin_cap = admin::mint_for_test(&mut ctx); + let mut state = state::new_for_test(&mut ctx); + let current_cap = state.current_cap(); let clock = clock::create_for_testing(&mut ctx); // Add the trusted signer that matches the test data let trusted_pubkey = TEST_TRUSTED_SIGNER_PUBKEY; let expiry_time = 2_000_000_000_000; // Far in the future - state::update_trusted_signer(&admin_cap, &mut s, trusted_pubkey, expiry_time); + state.update_trusted_signer(¤t_cap, trusted_pubkey, expiry_time); - let update = parse_and_verify_le_ecdsa_update(&s, &clock, TEST_LAZER_UPDATE); + let update = parse_and_verify_le_ecdsa_update(&state, &clock, TEST_LAZER_UPDATE); // If we reach this point, the function successfully verified & parsed the payload (no assertion failures) // Validate that the fields have correct values - assert!(update.timestamp() == 1755625313400000, 0); - assert!(update.channel() == new_fixed_rate_200ms(), 0); - assert!(update.feeds_ref().length() == 3, 0); + assert_eq!(update.timestamp(), 1755625313400000); + assert_eq!(update.channel(), new_fixed_rate_200ms()); + assert_eq!(update.feeds_ref().length(), 3); let feed_1 = &update.feeds_ref()[0]; - assert!(feed_1.feed_id() == 1, 0); - assert!(feed_1.price() == option::some(option::some(i64::from_u64(11350721594969))), 0); - assert!( - feed_1.best_bid_price() == option::some(option::some(i64::from_u64(11350696257890))), - 0, + assert_eq!(feed_1.feed_id(), 1); + assert_eq!( + feed_1.price(), + option::some(option::some(i64::from_u64(11350721594969))) + ); + assert_eq!( + feed_1.best_bid_price(), + option::some(option::some(i64::from_u64(11350696257890))), ); - assert!( - feed_1.best_ask_price() == option::some(option::some(i64::from_u64(11350868428965))), - 0, + assert_eq!( + feed_1.best_ask_price(), + option::some(option::some(i64::from_u64(11350868428965))), ); - assert!(feed_1.exponent() == option::some(i16::new(8, true)), 0); - assert!(feed_1.publisher_count() == option::none(), 0); - assert!(feed_1.confidence() == option::none(), 0); - assert!(feed_1.funding_rate() == option::some(option::none()), 0); - assert!(feed_1.funding_timestamp() == option::some(option::none()), 0); - assert!(feed_1.funding_rate_interval() == option::some(option::none()), 0); + assert_eq!(feed_1.exponent(), option::some(i16::new(8, true))); + assert_eq!(feed_1.publisher_count(), option::none()); + assert_eq!(feed_1.confidence(), option::none()); + assert_eq!(feed_1.funding_rate(), option::some(option::none())); + assert_eq!(feed_1.funding_timestamp(), option::some(option::none())); + assert_eq!(feed_1.funding_rate_interval(), option::some(option::none())); let feed_2 = &update.feeds_ref()[1]; - assert!(feed_2.feed_id() == 2, 0); - assert!(feed_2.price() == option::some(option::some(i64::from_u64(417775510136))), 0); - assert!(feed_2.best_bid_price() == option::some(option::some(i64::from_u64(417771266475))), 0); - assert!(feed_2.best_ask_price() == option::some(option::some(i64::from_u64(417782074042))), 0); - assert!(feed_2.exponent() == option::some(i16::new(8, true)), 0); - assert!(feed_2.publisher_count() == option::none(), 0); - assert!(feed_2.confidence() == option::none(), 0); - assert!(feed_2.funding_rate() == option::some(option::none()), 0); - assert!(feed_2.funding_timestamp() == option::some(option::none()), 0); - assert!(feed_2.funding_rate_interval() == option::some(option::none()), 0); + assert_eq!(feed_2.feed_id(), 2); + assert_eq!(feed_2.price(), option::some(option::some(i64::from_u64(417775510136)))); + assert_eq!(feed_2.best_bid_price(), option::some(option::some(i64::from_u64(417771266475)))); + assert_eq!(feed_2.best_ask_price(), option::some(option::some(i64::from_u64(417782074042)))); + assert_eq!(feed_2.exponent(), option::some(i16::new(8, true))); + assert_eq!(feed_2.publisher_count(), option::none()); + assert_eq!(feed_2.confidence(), option::none()); + assert_eq!(feed_2.funding_rate(), option::some(option::none())); + assert_eq!(feed_2.funding_timestamp(), option::some(option::none())); + assert_eq!(feed_2.funding_rate_interval(), option::some(option::none())); let feed_3 = &update.feeds_ref()[2]; - assert!(feed_3.feed_id() == 112, 0); - assert!(feed_3.price() == option::some(option::some(i64::from_u64(113747064619385816))), 0); - assert!(feed_3.best_bid_price() == option::some(option::none()), 0); - assert!(feed_3.best_ask_price() == option::some(option::none()), 0); - assert!(feed_3.exponent() == option::some(i16::new(12, true)), 0); - assert!(feed_3.publisher_count() == option::none(), 0); - assert!(feed_3.confidence() == option::none(), 0); - assert!(feed_3.funding_rate() == option::some(option::some(i64::from_u64(31670000))), 0); - assert!(feed_3.funding_timestamp() == option::some(option::some(1755619200000000)), 0); - assert!(feed_3.funding_rate_interval() == option::some(option::some(28800000000)), 0); + assert_eq!(feed_3.feed_id(), 112); + assert_eq!(feed_3.price(), option::some(option::some(i64::from_u64(113747064619385816)))); + assert_eq!(feed_3.best_bid_price(), option::some(option::none())); + assert_eq!(feed_3.best_ask_price(), option::some(option::none())); + assert_eq!(feed_3.exponent(), option::some(i16::new(12, true))); + assert_eq!(feed_3.publisher_count(), option::none()); + assert_eq!(feed_3.confidence(), option::none()); + assert_eq!(feed_3.funding_rate(), option::some(option::some(i64::from_u64(31670000)))); + assert_eq!(feed_3.funding_timestamp(), option::some(option::some(1755619200000000))); + assert_eq!(feed_3.funding_rate_interval(), option::some(option::some(28800000000))); // Clean up - s.destroy_for_test(); - admin_cap.destroy_for_test(); + destroy(state); clock.destroy_for_testing(); } #[test] public fun test_verify_le_ecdsa_message_success() { let mut ctx = tx_context::dummy(); - let mut s = state::new_for_test(&mut ctx); - let admin_cap = admin::mint_for_test(&mut ctx); + let mut state = state::new_for_test(&mut ctx); + let current_cap = state.current_cap(); let clock = clock::create_for_testing(&mut ctx); // Add the trusted signer let expiry_time = 20_000_000_000_000; // Far in the future - state::update_trusted_signer(&admin_cap, &mut s, TEST_TRUSTED_SIGNER_PUBKEY, expiry_time); + state.update_trusted_signer(¤t_cap, TEST_TRUSTED_SIGNER_PUBKEY, expiry_time); // This should succeed - verify_le_ecdsa_message(&s, &clock, &TEST_SIGNATURE, &TEST_PAYLOAD); + verify_le_ecdsa_message(&state, &clock, &TEST_SIGNATURE, &TEST_PAYLOAD); // Clean up - s.destroy_for_test(); - admin_cap.destroy_for_test(); + destroy(state); clock.destroy_for_testing(); } #[test, expected_failure(abort_code = ESignerNotTrusted)] public fun test_verify_le_ecdsa_message_no_signers() { let mut ctx = tx_context::dummy(); - let s = state::new_for_test(&mut ctx); + let state = state::new_for_test(&mut ctx); let clock = clock::create_for_testing(&mut ctx); // Don't add any trusted signers - this should fail with ESignerNotTrusted - verify_le_ecdsa_message(&s, &clock, &TEST_SIGNATURE, &TEST_PAYLOAD); + verify_le_ecdsa_message(&state, &clock, &TEST_SIGNATURE, &TEST_PAYLOAD); // Clean up - s.destroy_for_test(); + destroy(state); clock.destroy_for_testing(); } #[test, expected_failure(abort_code = ESignerNotTrusted)] public fun test_verify_le_ecdsa_message_untrusted_signer() { let mut ctx = tx_context::dummy(); - let mut s = state::new_for_test(&mut ctx); - let admin_cap = admin::mint_for_test(&mut ctx); + let mut state = state::new_for_test(&mut ctx); + let current_cap = state.current_cap(); let clock = clock::create_for_testing(&mut ctx); // Add signers that don't match the signature let trusted_pubkey1 = x"03aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; let trusted_pubkey2 = x"03bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; - state::update_trusted_signer(&admin_cap, &mut s, trusted_pubkey1, 1_000_000_000_000); - state::update_trusted_signer(&admin_cap, &mut s, trusted_pubkey2, 1_000_000_000_000); + state.update_trusted_signer(¤t_cap, trusted_pubkey1, 1_000_000_000_000); + state.update_trusted_signer(¤t_cap, trusted_pubkey2, 1_000_000_000_000); // This should still fail with ESignerNotTrusted since the signature doesn't match any of the signers - verify_le_ecdsa_message(&s, &clock, &TEST_SIGNATURE, &TEST_PAYLOAD); + verify_le_ecdsa_message(&state, &clock, &TEST_SIGNATURE, &TEST_PAYLOAD); // Clean up - s.destroy_for_test(); - admin_cap.destroy_for_test(); + destroy(state); clock.destroy_for_testing(); } #[test] public fun test_verify_le_ecdsa_message_nearly_expired_signer() { let mut ctx = tx_context::dummy(); - let mut s = state::new_for_test(&mut ctx); - let admin_cap = admin::mint_for_test(&mut ctx); + let mut state = state::new_for_test(&mut ctx); + let current_cap = state.current_cap(); let mut clock = clock::create_for_testing(&mut ctx); let expiry_time = 1_000_000_000_000; clock.set_for_testing(expiry_time * 1000 - 1); // Advance clock right before signer expiry // Add an signer - state::update_trusted_signer(&admin_cap, &mut s, TEST_TRUSTED_SIGNER_PUBKEY, expiry_time); + state.update_trusted_signer(¤t_cap, TEST_TRUSTED_SIGNER_PUBKEY, expiry_time); // This should succeed - verify_le_ecdsa_message(&s, &clock, &TEST_SIGNATURE, &TEST_PAYLOAD); + verify_le_ecdsa_message(&state, &clock, &TEST_SIGNATURE, &TEST_PAYLOAD); // Clean up - s.destroy_for_test(); - admin_cap.destroy_for_test(); + destroy(state); clock.destroy_for_testing(); } #[test, expected_failure(abort_code = ESignerExpired)] public fun test_verify_le_ecdsa_message_expired_signer() { let mut ctx = tx_context::dummy(); - let mut s = state::new_for_test(&mut ctx); - let admin_cap = admin::mint_for_test(&mut ctx); + let mut state = state::new_for_test(&mut ctx); + let current_cap = state.current_cap(); let mut clock = clock::create_for_testing(&mut ctx); let expiry_time = 1_000_000_000_000; clock.set_for_testing(expiry_time * 1000); // Advance clock to signer expiry // Add an expired signer - state::update_trusted_signer(&admin_cap, &mut s, TEST_TRUSTED_SIGNER_PUBKEY, expiry_time); + state.update_trusted_signer(¤t_cap, TEST_TRUSTED_SIGNER_PUBKEY, expiry_time); // This should fail with ESignerExpired - verify_le_ecdsa_message(&s, &clock, &TEST_SIGNATURE, &TEST_PAYLOAD); + verify_le_ecdsa_message(&state, &clock, &TEST_SIGNATURE, &TEST_PAYLOAD); // Clean up - s.destroy_for_test(); - admin_cap.destroy_for_test(); + destroy(state); clock.destroy_for_testing(); } #[test, expected_failure(abort_code = ESignerExpired)] public fun test_verify_le_ecdsa_message_recently_expired_signer() { let mut ctx = tx_context::dummy(); - let mut s = state::new_for_test(&mut ctx); - let admin_cap = admin::mint_for_test(&mut ctx); + let mut state = state::new_for_test(&mut ctx); + let current_cap = state.current_cap(); let mut clock = clock::create_for_testing(&mut ctx); let expiry_time = 1_000_000_000_000; clock.set_for_testing(expiry_time * 1000 + 1); // Advance clock right past signer expiry // Add an expired signer - state::update_trusted_signer(&admin_cap, &mut s, TEST_TRUSTED_SIGNER_PUBKEY, expiry_time); + state.update_trusted_signer(¤t_cap, TEST_TRUSTED_SIGNER_PUBKEY, expiry_time); // This should fail with ESignerExpired - verify_le_ecdsa_message(&s, &clock, &TEST_SIGNATURE, &TEST_PAYLOAD); + verify_le_ecdsa_message(&state, &clock, &TEST_SIGNATURE, &TEST_PAYLOAD); // Clean up - s.destroy_for_test(); - admin_cap.destroy_for_test(); + destroy(state); clock.destroy_for_testing(); } #[test] public fun test_verify_le_ecdsa_message_multiple_signers() { let mut ctx = tx_context::dummy(); - let mut s = state::new_for_test(&mut ctx); - let admin_cap = admin::mint_for_test(&mut ctx); + let mut state = state::new_for_test(&mut ctx); + let current_cap = state.current_cap(); let clock = clock::create_for_testing(&mut ctx); // Add multiple trusted signers @@ -259,56 +263,54 @@ public fun test_verify_le_ecdsa_message_multiple_signers() { let trusted_pubkey2 = TEST_TRUSTED_SIGNER_PUBKEY; // This does let expiry_time = 1_000_000_000_000; - state::update_trusted_signer(&admin_cap, &mut s, trusted_pubkey1, expiry_time); - state::update_trusted_signer(&admin_cap, &mut s, trusted_pubkey2, expiry_time); + state.update_trusted_signer(¤t_cap, trusted_pubkey1, expiry_time); + state.update_trusted_signer(¤t_cap, trusted_pubkey2, expiry_time); // This should succeed because trusted_pubkey2 matches the signature - verify_le_ecdsa_message(&s, &clock, &TEST_SIGNATURE, &TEST_PAYLOAD); + verify_le_ecdsa_message(&state, &clock, &TEST_SIGNATURE, &TEST_PAYLOAD); // Clean up - s.destroy_for_test(); - admin_cap.destroy_for_test(); + destroy(state); clock.destroy_for_testing(); } // === NEGATIVE PARSING TESTS === -#[test, expected_failure(abort_code = EInvalidMagic)] +#[test, expected_failure(abort_code = EInvalidUpdateMagic)] public fun test_parse_invalid_update_magic() { let mut ctx = tx_context::dummy(); - let mut s = state::new_for_test(&mut ctx); - let admin_cap = admin::mint_for_test(&mut ctx); + let mut state = state::new_for_test(&mut ctx); + let current_cap = state.current_cap(); let clock = clock::create_for_testing(&mut ctx); // Add the trusted signer let trusted_pubkey = TEST_TRUSTED_SIGNER_PUBKEY; let expiry_time = 2_000_000_000_000; // Far in the future - state::update_trusted_signer(&admin_cap, &mut s, trusted_pubkey, expiry_time); + state.update_trusted_signer(¤t_cap, trusted_pubkey, expiry_time); // Create update with invalid magic (first 4 bytes corrupted) let mut invalid_update = TEST_LAZER_UPDATE; *vector::borrow_mut(&mut invalid_update, 0) = 0xFF; // Corrupt the magic - // This should fail with EInvalidMagic - parse_and_verify_le_ecdsa_update(&s, &clock, invalid_update); + // This should fail with EInvalidUpdateMagic + parse_and_verify_le_ecdsa_update(&state, &clock, invalid_update); // Clean up - s.destroy_for_test(); - admin_cap.destroy_for_test(); + destroy(state); clock.destroy_for_testing(); } -#[test, expected_failure(abort_code = EInvalidMagic)] +#[test, expected_failure(abort_code = EInvalidPayloadMagic)] public fun test_parse_invalid_payload_magic() { let mut ctx = tx_context::dummy(); - let mut s = state::new_for_test(&mut ctx); - let admin_cap = admin::mint_for_test(&mut ctx); + let mut state = state::new_for_test(&mut ctx); + let current_cap = state.current_cap(); let clock = clock::create_for_testing(&mut ctx); // Add the trusted signer let trusted_pubkey = TEST_TRUSTED_SIGNER_PUBKEY; let expiry_time = 2_000_000_000_000; // Far in the future - state::update_trusted_signer(&admin_cap, &mut s, trusted_pubkey, expiry_time); + state.update_trusted_signer(¤t_cap, trusted_pubkey, expiry_time); // Create update with invalid payload magic // The payload magic starts at byte 69 (4 bytes magic + 65 bytes signature + 2 payload length) @@ -316,25 +318,24 @@ public fun test_parse_invalid_payload_magic() { *invalid_update.borrow_mut(71) = 0xFF; // Corrupt the payload magic // This corrupts the payload magic, so expect EInvalidMagic - parse_and_verify_le_ecdsa_update(&s, &clock, invalid_update); + parse_and_verify_le_ecdsa_update(&state, &clock, invalid_update); // Clean up - s.destroy_for_test(); - admin_cap.destroy_for_test(); + destroy(state); clock.destroy_for_testing(); } #[test, expected_failure(abort_code = EInvalidPayloadLength)] public fun test_parse_invalid_payload_length() { let mut ctx = tx_context::dummy(); - let mut s = state::new_for_test(&mut ctx); - let admin_cap = admin::mint_for_test(&mut ctx); + let mut state = state::new_for_test(&mut ctx); + let current_cap = state.current_cap(); let clock = clock::create_for_testing(&mut ctx); // Add the trusted signer let trusted_pubkey = TEST_TRUSTED_SIGNER_PUBKEY; let expiry_time = 2_000_000_000_000; // Far in the future - state::update_trusted_signer(&admin_cap, &mut s, trusted_pubkey, expiry_time); + state.update_trusted_signer(¤t_cap, trusted_pubkey, expiry_time); // Create update with wrong payload length // Layout: magic(4) + signature(65) + payload_len(2) + payload... @@ -343,34 +344,32 @@ public fun test_parse_invalid_payload_length() { *invalid_update.borrow_mut(69) = 0xFF; // Set payload length too high // This should fail with EInvalidPayloadLength because payload length validation happens before signature verification - parse_and_verify_le_ecdsa_update(&s, &clock, invalid_update); + parse_and_verify_le_ecdsa_update(&state, &clock, invalid_update); // Clean up - s.destroy_for_test(); - admin_cap.destroy_for_test(); + destroy(state); clock.destroy_for_testing(); } #[test, expected_failure(abort_code = 0, location = sui::bcs)] public fun test_parse_truncated_data() { let mut ctx = tx_context::dummy(); - let mut s = state::new_for_test(&mut ctx); - let admin_cap = admin::mint_for_test(&mut ctx); + let mut state = state::new_for_test(&mut ctx); + let current_cap = state.current_cap(); let clock = clock::create_for_testing(&mut ctx); // Add the trusted signer let trusted_pubkey = TEST_TRUSTED_SIGNER_PUBKEY; let expiry_time = 2_000_000_000_000; // Far in the future - state::update_trusted_signer(&admin_cap, &mut s, trusted_pubkey, expiry_time); + state.update_trusted_signer(¤t_cap, trusted_pubkey, expiry_time); // Create truncated update (only first 50 bytes) let truncated_update = TEST_LAZER_UPDATE.take(50); // This should fail with BCS EOutOfRange error when trying to read beyond available data - parse_and_verify_le_ecdsa_update(&s, &clock, truncated_update); + parse_and_verify_le_ecdsa_update(&state, &clock, truncated_update); // Clean up - s.destroy_for_test(); - admin_cap.destroy_for_test(); + destroy(state); clock.destroy_for_testing(); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5f3c1862ac..13c593f199 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1968,31 +1968,40 @@ importers: '@mysten/sui': specifier: 'catalog:' version: 1.26.1(typescript@5.9.3) - '@pythnetwork/pyth-lazer-sdk': - specifier: workspace:* - version: link:../../../../sdk/js - '@types/yargs': - specifier: 'catalog:' - version: 17.0.33 - yargs: - specifier: 'catalog:' - version: 18.0.0 devDependencies: '@cprussin/eslint-config': specifier: 'catalog:' version: 4.0.2(@testing-library/dom@10.4.1)(@typescript-eslint/eslint-plugin@8.29.0(@typescript-eslint/parser@8.29.0(eslint@9.23.0(jiti@2.4.2))(typescript@5.9.3))(eslint@9.23.0(jiti@2.4.2))(typescript@5.9.3))(@typescript-eslint/parser@8.29.0(eslint@9.23.0(jiti@2.4.2))(typescript@5.9.3))(eslint@9.23.0(jiti@2.4.2))(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@22.14.0)(typescript@5.9.3)))(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@22.14.0)(typescript@5.9.3))(turbo@2.5.8)(typescript@5.9.3) + '@cprussin/prettier-config': + specifier: 'catalog:' + version: 2.2.2(prettier@3.5.3) '@cprussin/tsconfig': specifier: 'catalog:' version: 3.1.2(typescript@5.9.3) + '@pythnetwork/jest-config': + specifier: 'workspace:' + version: link:../../../../../packages/jest-config + '@pythnetwork/pyth-lazer-sdk': + specifier: workspace:* + version: link:../../../../sdk/js + '@types/jest': + specifier: 'catalog:' + version: 29.5.14 '@types/node': specifier: 'catalog:' version: 22.14.0 + '@types/yargs': + specifier: 'catalog:' + version: 17.0.33 eslint: specifier: 'catalog:' version: 9.23.0(jiti@2.4.2) - prettier: + jest: specifier: 'catalog:' - version: 3.5.3 + version: 29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@22.14.0)(typescript@5.9.3)) + yargs: + specifier: 'catalog:' + version: 18.0.0 lazer/sdk/js: dependencies: