diff --git a/.env.example b/.env.example index f37c347..c8ece1d 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,9 @@ HC_RPC_ENDPOINT=https://rpc.akashnet.net:443 # Pyth contract address on Akash HC_CONTRACT_ADDRESS=akash1... +# Pyth API key used for price updates and router-set upgrade VAAs +HC_HERMES_API_KEY=your_pyth_api_key + # Wallet mnemonic for signing transactions (12 or 24 words) # WARNING: Keep this secure! Never commit to version control. HC_WALLET_SECRET="mnemonic:your twelve or twenty four word mnemonic phrase here" @@ -19,8 +22,10 @@ HC_WALLET_SECRET="mnemonic:your twelve or twenty four word mnemonic phrase here" # Optional Settings # =================== -# Pyth Hermes API endpoint (default: https://hermes.pyth.network) -# HC_HERMES_ENDPOINT=https://hermes.pyth.network +# Pyth Hermes API endpoint (default: https://pyth.dourolabs.app/hermes) +# Price updates use /v2/updates/price/latest. +# Router rotations use /v1/guardian_set_upgrade_vaa on the same base URL. +# HC_HERMES_ENDPOINT=https://pyth.dourolabs.app/hermes # Price deviation tolerance, default=0 (e.g., "100" or "5%") # HC_PRICE_DEVIATION_TOLERANCE=0 diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 6ff42dd..d04ee01 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,6 +1,10 @@ --- version: 2 +release: + prerelease: auto + make_latest: '{{ if .Prerelease }}false{{ else }}true{{ end }}' + builds: - skip: true @@ -10,8 +14,8 @@ dockers_v2: - "ghcr.io/akash-network/hermes" tags: - "{{ .Tag }}" - - "v{{ .Major }}.{{ .Minor }}" - - "latest" + - "{{ if not .Prerelease }}v{{ .Major }}.{{ .Minor }}{{ end }}" + - "{{ if not .Prerelease }}latest{{ end }}" dockerfile: Dockerfile platforms: - linux/amd64 diff --git a/README.md b/README.md index ad6e5b7..02cf7cc 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,10 @@ cp .env.example .env Edit `.env`: ```bash HC_RPC_ENDPOINT=https://rpc.akashnet.net:443 +HC_HERMES_ENDPOINT=https://pyth.dourolabs.app/hermes +HC_HERMES_API_KEY=your_pyth_api_key HC_CONTRACT_ADDRESS=akash1your_contract_address -HC_MNEMONIC="your twelve or twenty four word mnemonic" +HC_WALLET_SECRET="mnemonic:your twelve or twenty four word mnemonic" ``` ### 3. Run @@ -171,10 +173,10 @@ akash query wasm contract-state smart $HC_CONTRACT_ADDRESS '{"get_config":{}}' | Variable | Required | Default | Description | |----------|----------|---------|-------------| | `HC_RPC_ENDPOINT` | Yes | - | Akash RPC endpoint | -| `HC_HERMES_API_KEY` | No | - | optional hermes api key. After 31 Jul 2026 it will become required | +| `HC_HERMES_API_KEY` | Yes | - | Pyth API key sent as `Authorization: Bearer` | | `HC_CONTRACT_ADDRESS` | Yes | - | Oracle contract address | | `HC_WALLET_SECRET` | Yes | - | Either `privateKey:` or `mnemonic:<12/24 words>` | -| `HC_HERMES_ENDPOINT` | No | `https://hermes.pyth.network` | Pyth Hermes API | +| `HC_HERMES_ENDPOINT` | No | `https://pyth.dourolabs.app/hermes` | Upgraded Pyth Hermes API. Price updates and router-set upgrade VAAs use this base URL | | `HC_PRICE_DEVIATION_TOLERANCE` | No | - | absolute or percentage value for price deviations which should be ignored (e.g., `100` or `10%`). When unset, every price with a newer publish time is submitted | | `HC_PRICE_FETCHING_METHOD` | No | polling | `polling` or `sse` | | `HC_PRICE_UPDATE_TX_METHOD` | No | ordered | `ordered` or `unordered` | @@ -227,7 +229,7 @@ HC_UPDATE_INTERVAL_MS=600000 # 10 minutes - Query contract for price_feed_id ↓ 2. Fetch price from Pyth Hermes - GET https://hermes.pyth.network/v2/updates/price/latest?ids={price_feed_id} + GET https://pyth.dourolabs.app/hermes/v2/updates/price/latest?ids={price_feed_id} ↓ 3. Query current price from contract ↓ @@ -239,6 +241,29 @@ HC_UPDATE_INTERVAL_MS=600000 # 10 minutes - Repeat from step 2 ``` +If the price update fails with `InvalidRouterSetIndex`, the client queries the +`pyth_vaa` address from `pyth_pro.get_config`, fetches the signed router-set +upgrade VAA from: + +```bash +GET $HC_HERMES_ENDPOINT/v1/guardian_set_upgrade_vaa +``` + +The endpoint returns `{ "vaa": "" }` when an +upgrade is active and `404 no guardian set upgrade in progress` otherwise. The +client validates the VAA against the current `pyth_vaa` router config, converts +the hex bytes to base64, submits: + +```json +{ + "submit_v_a_a": { + "vaa": "" + } +} +``` + +to `pyth_vaa`, then retries the original price update. + ### Smart Update Logic The client only submits transactions when: @@ -317,12 +342,23 @@ akash tx bank send 100000000uakt --gas auto **"Failed to fetch from Hermes"** ```bash # Test Hermes API -curl "https://hermes.pyth.network/v2/updates/price/latest?ids=" +curl -H "Authorization: Bearer $HC_HERMES_API_KEY" \ + "$HC_HERMES_ENDPOINT/v2/updates/price/latest?ids[]=" # Check price feed ID akash query wasm contract-state smart $HC_CONTRACT_ADDRESS '{"get_price_feed_id":{}}' ``` +**"InvalidRouterSetIndex"** +```bash +# Check whether Pyth is serving a router-set upgrade VAA +curl -i -H "Authorization: Bearer $HC_HERMES_API_KEY" \ + "$HC_HERMES_ENDPOINT/v1/guardian_set_upgrade_vaa" +``` + +`404 no guardian set upgrade in progress` means Pyth is not serving a rotation +VAA at that moment. + **"Price already up to date"** - Not an error! Contract already has the latest price - Client will try again on next interval @@ -342,7 +378,8 @@ npm run cli:daemon curl $HC_RPC_ENDPOINT/status # Test Hermes -curl "https://hermes.pyth.network/api/latest_price_feeds?ids[]=" +curl -H "Authorization: Bearer $HC_HERMES_API_KEY" \ + "$HC_HERMES_ENDPOINT/v2/updates/price/latest?ids[]=" # Test contract akash query wasm contract-state smart $HC_CONTRACT_ADDRESS '{"get_config":{}}' @@ -356,7 +393,7 @@ For more detailed information: - **API Reference** - See `src/hermes-client.ts` JSDoc comments - **CLI Reference** - Run `npm run cli -- --help` - **Pyth Network** - https://docs.pyth.network/ -- **Hermes API** - https://hermes.pyth.network/docs/ +- **Hermes API** - https://pyth.dourolabs.app/docs/?urls.primaryName=Hermes%20API ## 🆘 Support diff --git a/package-lock.json b/package-lock.json index fdcb9ac..c94625a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@cosmjs/proto-signing": "^0.38.0", "@cosmjs/stargate": "^0.38.0", "@noble/curves": "^1.9.7", + "@noble/hashes": "^1.8.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-prometheus": "^0.211.0", "@opentelemetry/instrumentation-runtime-node": "^0.24.0", diff --git a/package.json b/package.json index 9bf94b4..efd0c7f 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@cosmjs/proto-signing": "^0.38.0", "@cosmjs/stargate": "^0.38.0", "@noble/curves": "^1.9.7", + "@noble/hashes": "^1.8.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-prometheus": "^0.211.0", "@opentelemetry/instrumentation-runtime-node": "^0.24.0", diff --git a/src/cli-commands/command-config.test.ts b/src/cli-commands/command-config.test.ts index 434d533..f4dbf57 100644 --- a/src/cli-commands/command-config.test.ts +++ b/src/cli-commands/command-config.test.ts @@ -13,6 +13,7 @@ vi.mock("../price-stream/price-sse-stream/price-sse-stream.ts", () => ({ describe("parseConfig", () => { it("returns error when CONTRACT_ADDRESS is missing", () => { const result = parseConfig({ + HERMES_API_KEY: "secret-token", WALLET_SECRET: "mnemonic:abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", }); @@ -21,7 +22,10 @@ describe("parseConfig", () => { }); it("returns error when WALLET_SECRET is missing", () => { - const result = parseConfig({ CONTRACT_ADDRESS: "akash1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5lzv7xu" }); + const result = parseConfig({ + CONTRACT_ADDRESS: "akash1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5lzv7xu", + HERMES_API_KEY: "secret-token", + }); expect(result.ok).toBe(false); expect((result as Extract).error).toContain("WALLET_SECRET"); @@ -85,6 +89,18 @@ describe("parseConfig", () => { expect(result.ok).toBe(true); expect((result as Extract).value.priceProducerFactory).toBeTypeOf("function"); + expect((result as Extract).value.rawConfig.HERMES_ENDPOINT).toBe( + "https://pyth.dourolabs.app/hermes", + ); + }); + + it("ignores legacy PYTH_ROUTER_ENDPOINTS", () => { + const result = parseConfig(validEnv({ + PYTH_ROUTER_ENDPOINTS: "https://router-0.example/v1, https://router-1.example/v1,https://router-2.example/v1", + })); + + expect(result.ok).toBe(true); + expect((result as Extract).value.rawConfig).not.toHaveProperty("PYTH_ROUTER_ENDPOINTS"); }); it("accepts UPDATE_INTERVAL_MS and produces priceProducerFactory", () => { @@ -223,18 +239,18 @@ describe("parseConfig", () => { vi.clearAllMocks(); }); - it("is optional and config parses when it is not provided", () => { - const result = parseConfig(validEnv()); + it("returns error when HERMES_API_KEY is not provided", () => { + const result = parseConfig(validEnv({ HERMES_API_KEY: undefined })); - expect(result.ok).toBe(true); - expect((result as Extract).value.rawConfig.HERMES_API_KEY).toBeUndefined(); + expect(result.ok).toBe(false); + expect((result as Extract).error).toContain("HERMES_API_KEY"); }); it("parses config when HERMES_API_KEY is provided", () => { - const result = parseConfig(validEnv({ HERMES_API_KEY: "secret-token" })); + const result = parseConfig(validEnv({ HERMES_API_KEY: "other-token" })); expect(result.ok).toBe(true); - expect((result as Extract).value.rawConfig.HERMES_API_KEY).toBe("secret-token"); + expect((result as Extract).value.rawConfig.HERMES_API_KEY).toBe("other-token"); }); it("passes authenticationToken to pollPriceStream when method is polling", () => { @@ -259,14 +275,14 @@ describe("parseConfig", () => { ); }); - it("passes undefined authenticationToken when HERMES_API_KEY is not set", () => { + it("passes authenticationToken to pollPriceStream from the required API key", () => { const result = parseConfig(validEnv()); expect(result.ok).toBe(true); (result as Extract).value.priceProducerFactory({ priceFeedId: "abc123" }); expect(pollPriceStream).toHaveBeenCalledWith( - expect.objectContaining({ authenticationToken: undefined }), + expect.objectContaining({ authenticationToken: "secret-token" }), ); }); }); @@ -274,6 +290,7 @@ describe("parseConfig", () => { function validEnv(overrides: Record = {}) { return { CONTRACT_ADDRESS: "akash1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5lzv7xu", + HERMES_API_KEY: "secret-token", WALLET_SECRET: "mnemonic:abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", ...overrides, }; diff --git a/src/cli-commands/command-config.ts b/src/cli-commands/command-config.ts index bddf772..12b6f04 100644 --- a/src/cli-commands/command-config.ts +++ b/src/cli-commands/command-config.ts @@ -5,6 +5,7 @@ import type { PriceProducerFactoryOptions } from "../types.ts"; import { pollPriceStream } from "../price-stream/polling-price-stream/polling-price-stream.ts"; import { priceSSEStream } from "../price-stream/price-sse-stream/price-sse-stream.ts"; import { ContractClientService, type SigningClientServiceConfig } from "../services/contract-client/contract-client.service.ts"; +import { DEFAULT_HERMES_ENDPOINT, RouterSetUpdater } from "../services/router-set-updater/router-set-updater.ts"; export interface CommandConfig extends HermesConfig { createHermesClient: (config: HermesConfig) => HermesClient; @@ -17,8 +18,8 @@ export interface CommandConfig extends HermesConfig { const configSchema = z.object({ RPC_ENDPOINT: z.url().default("https://rpc.akashnet.net:443"), - HERMES_ENDPOINT: z.url().default("https://hermes.pyth.network"), - HERMES_API_KEY: z.string().optional(), + HERMES_ENDPOINT: z.url().default(DEFAULT_HERMES_ENDPOINT), + HERMES_API_KEY: z.string().nonempty(), CONTRACT_ADDRESS: z.string().nonempty().superRefine(propagateError(validateContractAddress)), WALLET_SECRET: z.string() .regex(/^(mnemonic|privateKey):.+$/, { message: 'must be in the format "mnemonic:<12/24 word phrase>" or "privateKey:"' }) @@ -69,6 +70,17 @@ export function parseConfig(config: Record): ParseCo } const unsafeAllowInsecureEndpoints = result.data.NODE_ENV === "development"; // Enforce secure endpoints in production + let routerSetUpdater: RouterSetUpdater; + try { + routerSetUpdater = new RouterSetUpdater({ + endpoint: result.data.HERMES_ENDPOINT, + authenticationToken: result.data.HERMES_API_KEY, + unsafeAllowInsecureEndpoints, + }); + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : "Invalid HERMES_ENDPOINT" }; + } + const parsedConfig: ParsedConfig = { rawConfig: result.data, unsafeAllowInsecureEndpoints, @@ -84,6 +96,7 @@ export function parseConfig(config: Record): ParseCo insufficientBalanceRetryDelayMs: result.data.INSUFFICIENT_BALANCE_RETRY_DELAY_MS, priceUpdateTxMethod: result.data.PRICE_UPDATE_TX_METHOD, unorderedTxTtlMs: result.data.UNORDERED_TX_TTL_MS, + routerSetUpdater, priceProducerFactory(options: PriceProducerFactoryOptions) { if (result.data.PRICE_FETCHING_METHOD === "sse") { return priceSSEStream({ diff --git a/src/cli-commands/create-command-builder.test.ts b/src/cli-commands/create-command-builder.test.ts index d7be28d..001d743 100644 --- a/src/cli-commands/create-command-builder.test.ts +++ b/src/cli-commands/create-command-builder.test.ts @@ -144,6 +144,7 @@ function setup(env?: Record) { const mockProcess = Object.assign(new EventEmitter(), { env: env ?? { HC_CONTRACT_ADDRESS: "akash1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5lzv7xu", + HC_HERMES_API_KEY: "secret-token", HC_WALLET_SECRET: "mnemonic:abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", }, exit: vi.fn(), diff --git a/src/cli-commands/query-command/query-command.test.ts b/src/cli-commands/query-command/query-command.test.ts index 72c61f1..40a698a 100644 --- a/src/cli-commands/query-command/query-command.test.ts +++ b/src/cli-commands/query-command/query-command.test.ts @@ -25,10 +25,9 @@ describe("queryCommand", () => { admin: "akash1admin", update_fee: "100", price_feed_id: "feed-123", + pyth_vaa_contract: "akash1vaa", default_denom: "uakt", default_base_denom: "akt", - wormhole_contract: "akash1wormhole", - data_sources: [], }); await queryCommand(config, { config: true }); @@ -38,6 +37,7 @@ describe("queryCommand", () => { expect(logger.log).toHaveBeenCalledWith("Admin: akash1admin"); expect(logger.log).toHaveBeenCalledWith("Update Fee: 100"); expect(logger.log).toHaveBeenCalledWith("Price Feed ID: feed-123"); + expect(logger.log).toHaveBeenCalledWith("Pyth VAA: akash1vaa"); expect(logger.log).toHaveBeenCalledWith("Default Denom: uakt"); expect(logger.log).toHaveBeenCalledWith("Base Denom: akt"); }); diff --git a/src/cli-commands/query-command/query-command.ts b/src/cli-commands/query-command/query-command.ts index 9efd9e3..c78ff29 100644 --- a/src/cli-commands/query-command/query-command.ts +++ b/src/cli-commands/query-command/query-command.ts @@ -16,6 +16,7 @@ export async function queryCommand(config: CommandConfig, options: QueryOptions) config.logger?.log(`Admin: ${cfg.admin}`); config.logger?.log(`Update Fee: ${cfg.update_fee}`); config.logger?.log(`Price Feed ID: ${cfg.price_feed_id}`); + config.logger?.log(`Pyth VAA: ${cfg.pyth_vaa_contract}`); config.logger?.log(`Default Denom: ${cfg.default_denom}`); config.logger?.log(`Base Denom: ${cfg.default_base_denom}`); return; diff --git a/src/cli-commands/status-command/status-command.test.ts b/src/cli-commands/status-command/status-command.test.ts index cd934e2..edd0a2f 100644 --- a/src/cli-commands/status-command/status-command.test.ts +++ b/src/cli-commands/status-command/status-command.test.ts @@ -11,7 +11,7 @@ function setup() { rpcEndpoint: "https://rpc.akashnet.net:443", contractAddress: "akash1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5lzv7xu", rawConfig: { - HERMES_ENDPOINT: "https://hermes.pyth.network", + HERMES_ENDPOINT: "https://pyth.dourolabs.app/hermes", }, logger, createHermesClient: vi.fn(() => client), @@ -64,12 +64,12 @@ describe("statusCommand", () => { await statusCommand(config); expect(logger.log).toHaveBeenCalledWith("RPC Endpoint: https://rpc.akashnet.net:443"); - expect(logger.log).toHaveBeenCalledWith("Hermes Endpoint: https://hermes.pyth.network"); + expect(logger.log).toHaveBeenCalledWith("Hermes Endpoint: https://pyth.dourolabs.app/hermes"); }); it("uses default Hermes endpoint when not configured", async () => { const { config, client, logger } = setup(); - (config.rawConfig as Record).HERMES_ENDPOINT = "https://hermes.pyth.network"; + (config.rawConfig as Record).HERMES_ENDPOINT = "https://pyth.dourolabs.app/hermes"; client.getStatus.mockResolvedValueOnce({ address: "akash1sender", contractAddress: "akash1contract", @@ -79,6 +79,6 @@ describe("statusCommand", () => { await statusCommand(config); - expect(logger.log).toHaveBeenCalledWith("Hermes Endpoint: https://hermes.pyth.network"); + expect(logger.log).toHaveBeenCalledWith("Hermes Endpoint: https://pyth.dourolabs.app/hermes"); }); }); diff --git a/src/services/contract-client/contract-client.service.test.ts b/src/services/contract-client/contract-client.service.test.ts index b0e9eb2..e3b3682 100644 --- a/src/services/contract-client/contract-client.service.test.ts +++ b/src/services/contract-client/contract-client.service.test.ts @@ -19,6 +19,7 @@ const PRIVATE_KEY_ADDRESS = "akash1td2hmee6u8lt5n0fk3mwvme65zrj3vpf6anh6r"; const MNEMONIC = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; const MNEMONIC_ADDRESS = "akash19rl4cm2hmr8afy4kldpxz3fka4jguq0a3mq6x0"; const CONTRACT_ADDRESS = "akash1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5lzv7xu"; +const PYTH_VAA_CONTRACT_ADDRESS = "akash1fyr2mptjswz4w6xmgnpgm93x0q4s4wdl6srv3rtz3utc4f6fmxeqps4ws5"; const RPC_ENDPOINT = "https://rpc.akashnet.net:443"; describe(ContractClientService.name, () => { @@ -171,6 +172,70 @@ describe(ContractClientService.name, () => { }); }); + describe("submitRouterSetUpgrade", () => { + it("executes the router-set upgrade against the pyth_vaa contract without funds", async () => { + const { client, signingClient } = setup({ priceUpdateTxMethod: "ordered", gasMultiplier: 1.5 }); + const vaa = btoa("router-set-upgrade"); + + const result = await client.submitRouterSetUpgrade({ + pythVaaContract: PYTH_VAA_CONTRACT_ADDRESS, + vaa, + }); + + expect(signingClient.execute).toHaveBeenCalledWith( + PRIVATE_KEY_ADDRESS, + PYTH_VAA_CONTRACT_ADDRESS, + { submit_v_a_a: { vaa } }, + 1.5, + undefined, + undefined, + ); + expect(result).toEqual({ transactionHash: "ORDERED_TX_HASH", gasUsed: 120_000n }); + }); + + it("broadcasts an unordered tx carrying the router-set upgrade to pyth_vaa", async () => { + const { client, signingClient } = setup({ priceUpdateTxMethod: "unordered" }); + const vaa = btoa("router-set-upgrade"); + + await client.submitRouterSetUpgrade({ + pythVaaContract: PYTH_VAA_CONTRACT_ADDRESS, + vaa, + }); + + expect(signingClient.execute).not.toHaveBeenCalled(); + const body = TxBody.decode(TxRaw.decode(signingClient.broadcastTx.mock.calls[0][0]).bodyBytes); + expect(body.unordered).toBe(true); + expect(body.messages[0].typeUrl).toBe("/cosmwasm.wasm.v1.MsgExecuteContract"); + + const executeMsg = MsgExecuteContract.decode(body.messages[0].value); + expect(executeMsg.sender).toBe(PRIVATE_KEY_ADDRESS); + expect(executeMsg.contract).toBe(PYTH_VAA_CONTRACT_ADDRESS); + expect(executeMsg.funds).toEqual([]); + expect(JSON.parse(fromUtf8(executeMsg.msg))).toEqual({ submit_v_a_a: { vaa } }); + }); + + it("queries pyth_vaa config from the supplied pyth_vaa address", async () => { + const { client, signingClient } = setup(); + const config = { + admin: MNEMONIC_ADDRESS, + governance_target_chain: 29, + router_verifier: { + router_set_index: 1, + routers: Array.from({ length: 5 }, () => ({ bytes: "QVNLsXbkYaP7MEeUAPIQVJ7M5jg=" })), + expected_emitter_chain: 26, + expected_emitter_address: "UHl0aG5ldFB5dGhuZXRQeXRobmV0UHl0aG5ldFB5dGg=", + }, + }; + signingClient.queryContractSmart.mockResolvedValue(config); + + await expect(client.queryPythVaaConfig(PYTH_VAA_CONTRACT_ADDRESS)).resolves.toEqual(config); + expect(signingClient.queryContractSmart).toHaveBeenCalledWith( + PYTH_VAA_CONTRACT_ADDRESS, + { get_config: {} }, + ); + }); + }); + describe("contract queries", () => { it("queries the current price", async () => { const { client, signingClient } = setup(); @@ -370,12 +435,11 @@ describe(ContractClientService.name, () => { function createConfigResponse(): ConfigResponse { return { admin: MNEMONIC_ADDRESS, - wormhole_contract: CONTRACT_ADDRESS, + pyth_vaa_contract: CONTRACT_ADDRESS, update_fee: "250", price_feed_id: "feed-id", default_denom: "uakt", default_base_denom: "akt", - data_sources: [{ emitter_chain: 26, emitter_address: "e101faedac5851e32b9b23b5f9411a8c2bac4aae3ed4dd7b811dd1a72ea4aa71" }], }; } }); diff --git a/src/services/contract-client/contract-client.service.ts b/src/services/contract-client/contract-client.service.ts index d72c75f..94014a1 100644 --- a/src/services/contract-client/contract-client.service.ts +++ b/src/services/contract-client/contract-client.service.ts @@ -6,8 +6,8 @@ import { TxRaw } from "cosmjs-types/cosmos/tx/v1beta1/tx"; import { MsgExecuteContract } from "cosmjs-types/cosmwasm/wasm/v1/tx"; import { memoizeAsync } from "../../lib/caching/helpers/helpers.ts"; import { type SigningClient, SigningStargateClientService } from "../../lib/signing-stargate-client/signing-stargate-client.service.ts"; -import type { PriceUpdate, PriceUpdateOptions, PriceUpdater, UpdatePriceFeedMsg } from "../../types.ts"; -import { validateAkashAddress, validateFeeAmount } from "../../validation.ts"; +import type { PriceUpdate, PriceUpdateOptions, PriceUpdater, SubmitVaaMsg, UpdatePriceFeedMsg } from "../../types.ts"; +import { validateAkashAddress, validateContractAddress, validateFeeAmount } from "../../validation.ts"; export class ContractClientService implements PriceUpdater { readonly #config: SigningClientServiceConfig; @@ -56,14 +56,6 @@ export class ContractClientService implements PriceUpdater { return accountData; } - /** - * Prepare execute message with VAA - * The contract will: - * 1. Verify VAA via Wormhole contract - * 2. Parse Pyth price attestation from VAA payload - * 3. Validate price feed ID matches expected - * 4. Relay validated price to x/oracle module - */ async updatePrice(priceUpdate: PriceUpdate, options: PriceUpdateOptions): Promise<{ transactionHash: string; gasUsed?: bigint; @@ -75,13 +67,53 @@ export class ContractClientService implements PriceUpdater { }; if (this.#config.priceUpdateTxMethod === "unordered") { - return await this.#updatePriceInUnorderedTx(msg, options); + return await this.#executeInUnorderedTx({ + contractAddress: this.#config.contractAddress, + msg, + funds: [{ denom: this.#config.denom, amount: options.updateFee }], + }); + } + + return await this.#executeInOrderedTx({ + contractAddress: this.#config.contractAddress, + msg, + funds: [{ denom: this.#config.denom, amount: options.updateFee }], + }); + } + + async submitRouterSetUpgrade(input: { + pythVaaContract: string; + vaa: string; + }): Promise<{ + transactionHash: string; + gasUsed?: bigint; + }> { + validateContractAddress(input.pythVaaContract); + + const msg: SubmitVaaMsg = { + submit_v_a_a: { + vaa: input.vaa, + }, + }; + + if (this.#config.priceUpdateTxMethod === "unordered") { + return await this.#executeInUnorderedTx({ + contractAddress: input.pythVaaContract, + msg, + }); } - return await this.#updatePriceInOrderedTx(msg, options); + return await this.#executeInOrderedTx({ + contractAddress: input.pythVaaContract, + msg, + }); } - async #updatePriceInUnorderedTx(msg: UpdatePriceFeedMsg, options: PriceUpdateOptions) { + async #executeInUnorderedTx(input: { + contractAddress: string; + msg: ContractExecuteMsg; + funds?: ContractFunds; + }) { const [signingClient, account] = await Promise.all([ this.#getSigningClientWithUnorderedTxSupport(), this.getAccount(), @@ -92,9 +124,9 @@ export class ContractClientService implements PriceUpdater { typeUrl: "/cosmwasm.wasm.v1.MsgExecuteContract", value: MsgExecuteContract.fromPartial({ sender: account.address, - contract: this.#config.contractAddress, - msg: toUtf8(JSON.stringify(msg)), - funds: [{ denom: this.#config.denom, amount: options.updateFee }], + contract: input.contractAddress, + msg: toUtf8(JSON.stringify(input.msg)), + funds: input.funds ?? [], }), }, ]; @@ -110,18 +142,22 @@ export class ContractClientService implements PriceUpdater { }; } - async #updatePriceInOrderedTx(msg: UpdatePriceFeedMsg, options: PriceUpdateOptions) { + async #executeInOrderedTx(input: { + contractAddress: string; + msg: ContractExecuteMsg; + funds?: ContractFunds; + }) { const [signingClient, account] = await Promise.all([ this.#getSigningClient(), this.getAccount(), ]); const result = await signingClient.execute( account.address, - this.#config.contractAddress, - msg, + input.contractAddress, + input.msg, this.#config.gasMultiplier, undefined, - [{ denom: this.#config.denom, amount: options.updateFee }], + input.funds, ); return { transactionHash: result.transactionHash, @@ -177,6 +213,17 @@ export class ContractClientService implements PriceUpdater { return config; } + async queryPythVaaConfig(pythVaaContract: string): Promise { + validateContractAddress(pythVaaContract); + const signingClient = await this.#getSigningClient(); + const config: PythVaaConfigResponse = await signingClient.queryContractSmart( + pythVaaContract, + { get_config: {} }, + ); + + return config; + } + async queryOracleParams(): Promise { const signingClient = await this.#getSigningClient(); const params: OracleParamsResponse = await signingClient.queryContractSmart( @@ -316,22 +363,11 @@ export interface SigningClientServiceConfig { export interface ConfigResponse { admin: string; - wormhole_contract: string; + pyth_vaa_contract: string; update_fee: string; // Uint256 serializes as string price_feed_id: string; default_denom: string; default_base_denom: string; - data_sources: DataSourceResponse[]; -} - -// ===================== -// Contract Query Responses -// Matches Pyth contract msg.rs -// ===================== - -export interface DataSourceResponse { - emitter_chain: number; // u16 - Wormhole chain ID (26 for Pythnet) - emitter_address: string; // 32 bytes hex encoded } export interface PriceResponse { @@ -341,11 +377,6 @@ export interface PriceResponse { publish_time: number; // i64 } -// ===================== -// Contract Execute Messages -// Matches pyth contract msg.rs -// ===================== - interface UpdateFeeMsg { update_fee: { new_fee: string; // Uint256 serializes as string in JSON @@ -378,3 +409,17 @@ interface OracleParamsResponse { twap_window: number; // i64 last_updated_height: number; // u64 } + +export interface PythVaaConfigResponse { + admin: string; + governance_target_chain: number; + router_verifier: { + router_set_index: number; + routers: Array<{ bytes: string }>; + expected_emitter_chain: number; + expected_emitter_address: string; + }; +} + +type ContractExecuteMsg = UpdatePriceFeedMsg | SubmitVaaMsg; +type ContractFunds = Array<{ denom: string; amount: string }>; diff --git a/src/services/hermes-client/hermes-client.test.ts b/src/services/hermes-client/hermes-client.test.ts index 71aeb73..5d25295 100644 --- a/src/services/hermes-client/hermes-client.test.ts +++ b/src/services/hermes-client/hermes-client.test.ts @@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { mock } from "vitest-mock-extended"; import { ContractClient, HermesClient, HermesConfig, classifyError } from "./hermes-client.ts"; import { blockchainPriceStaleness, priceUpdateCounter } from "../../metrics.ts"; -import { BroadcastError, type ConfigResponse, type PriceResponse } from "../contract-client/contract-client.service.ts"; +import { BroadcastError, type ConfigResponse, type PriceResponse, type PythVaaConfigResponse } from "../contract-client/contract-client.service.ts"; import type { PriceUpdate, PriceProducerFactory, PriceProducerFactoryOptions } from "../../types.ts"; const CONTRACT_ADDRESS = "akash1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5lzv7xu"; @@ -147,6 +147,43 @@ describe(HermesClient.name, () => { expect(contractClient.updatePrice).toHaveBeenCalledWith(priceUpdate, { updateFee: "1" }); }); + it("submits a router-set upgrade to pyth_vaa and retries the price update when the router set is stale", async () => { + const routerSetUpdater = { + buildUpgradeVaa: vi.fn().mockResolvedValue({ + vaa: btoa("router-set-upgrade"), + currentRouterSetIndex: 0, + newRouterSetIndex: 1, + signatureCount: 3, + }), + }; + const { client, priceUpdate, contractClient } = setup({ + priceFeed: buildPriceFeed("12400", -2, 2000), + routerSetUpdater, + }); + const config = buildConfig({ + pyth_vaa_contract: "akash1fyr2mptjswz4w6xmgnpgm93x0q4s4wdl6srv3rtz3utc4f6fmxeqps4ws5", + }); + const pythVaaConfig = buildPythVaaConfig(); + contractClient.queryConfig.mockResolvedValue(config); + contractClient.queryCurrentPrice.mockResolvedValue(buildCurrentPrice("12345", -2, 1000)); + contractClient.queryPythVaaConfig.mockResolvedValue(pythVaaConfig); + contractClient.updatePrice + .mockRejectedValueOnce(new Error("InvalidRouterSetIndex: query wasm contract failed")) + .mockResolvedValueOnce({ transactionHash: "PRICE_TX", gasUsed: 500000n }); + contractClient.submitRouterSetUpgrade.mockResolvedValue({ transactionHash: "ROUTER_TX", gasUsed: 300000n }); + + await client.updatePrice(); + + expect(contractClient.updatePrice).toHaveBeenNthCalledWith(1, priceUpdate, { updateFee: "1" }); + expect(contractClient.queryPythVaaConfig).toHaveBeenCalledWith(config.pyth_vaa_contract); + expect(routerSetUpdater.buildUpgradeVaa).toHaveBeenCalledWith(pythVaaConfig); + expect(contractClient.submitRouterSetUpgrade).toHaveBeenCalledWith({ + pythVaaContract: config.pyth_vaa_contract, + vaa: btoa("router-set-upgrade"), + }); + expect(contractClient.updatePrice).toHaveBeenNthCalledWith(2, priceUpdate, { updateFee: "1" }); + }); + it("skips the update when no new price is available", async () => { const { client, contractClient, logger } = setup({ priceProducerFactory: (async function* () {}) as unknown as PriceProducerFactory, @@ -575,6 +612,51 @@ describe(HermesClient.name, () => { ); }); + it("continues processing prices when router-set upgrade submission fails", async () => { + const firstPriceUpdate = buildPriceFeed("10000", -2, 2000, "stale-router-vaa"); + const secondPriceUpdate = buildPriceFeed("10100", -2, 3000, "manual-rotation-price-vaa"); + const routerSetUpdater = { + buildUpgradeVaa: vi.fn().mockResolvedValue({ + vaa: btoa("router-set-upgrade"), + currentRouterSetIndex: 0, + newRouterSetIndex: 1, + signatureCount: 3, + }), + }; + const { promise: routerSubmissionAttempted, resolve: resolveRouterSubmissionAttempted } = Promise.withResolvers(); + const priceProducerFactory = vi.fn(async function* () { + yield firstPriceUpdate; + await routerSubmissionAttempted; + yield secondPriceUpdate; + }); + const { client, contractClient, logger } = setup({ + priceProducerFactory: priceProducerFactory as unknown as PriceProducerFactory, + routerSetUpdater, + }); + + contractClient.queryCurrentPrice.mockResolvedValue(buildCurrentPrice("9000", -2, 1000)); + contractClient.updatePrice + .mockRejectedValueOnce(new Error("InvalidRouterSetIndex: query wasm contract failed")) + .mockResolvedValueOnce({ transactionHash: "PRICE_TX", gasUsed: 500000n }); + contractClient.submitRouterSetUpgrade.mockImplementationOnce(async () => { + resolveRouterSubmissionAttempted(); + throw new Error("router tx broadcast failed"); + }); + + await client.start(); + + expect(contractClient.submitRouterSetUpgrade).toHaveBeenCalledWith({ + pythVaaContract: "akash1vaa", + vaa: btoa("router-set-upgrade"), + }); + expect(contractClient.updatePrice).toHaveBeenNthCalledWith(1, firstPriceUpdate, { updateFee: "1" }); + expect(contractClient.updatePrice).toHaveBeenNthCalledWith(2, secondPriceUpdate, { updateFee: "1" }); + expect(logger.error).toHaveBeenCalledWith( + "Error in scheduled update:", + expect.any(Error), + ); + }); + it("propagates a producer failure to the consumer", async () => { const factory = vi.fn(async function* () { yield buildPriceFeed("10000", -2, 2000); @@ -729,7 +811,9 @@ function setup(input?: Partial & { contractClient.getAccount.mockResolvedValue(buildAccount()); contractClient.queryConfig.mockResolvedValue(buildConfig()); contractClient.queryCurrentPrice.mockResolvedValue(buildCurrentPrice("0", -2, 0)); + contractClient.queryPythVaaConfig.mockResolvedValue(buildPythVaaConfig()); contractClient.updatePrice.mockResolvedValue({ transactionHash: "TX_DEFAULT", gasUsed: 500000n }); + contractClient.submitRouterSetUpgrade.mockResolvedValue({ transactionHash: "TX_ROUTER_SET", gasUsed: 300000n }); const logger = mock(); const client = new HermesClient({ @@ -747,7 +831,8 @@ function setup(input?: Partial & { contractClientFactory: () => contractClient, denom: "uakt", gasMultiplier: 1.5, - priceUpdateTxMethod: "ordered", + priceUpdateTxMethod: input?.priceUpdateTxMethod ?? "ordered", + routerSetUpdater: input?.routerSetUpdater, }); return { client, priceUpdate, priceProducerFactory, logger, contractClient }; @@ -771,12 +856,31 @@ function buildCurrentPrice(price: string, expo: number, publishTime: number): Pr function buildConfig(overrides?: Partial): ConfigResponse { return { admin: "akash1admin", - wormhole_contract: "akash1wormhole", + pyth_vaa_contract: "akash1vaa", update_fee: "1", price_feed_id: "test-feed-id", default_denom: "uakt", default_base_denom: "akt", - data_sources: [], + ...overrides, + }; +} + +function buildPythVaaConfig(overrides?: Partial): PythVaaConfigResponse { + return { + admin: "akash1admin", + governance_target_chain: 29, + router_verifier: { + router_set_index: 0, + routers: [ + "QVNLsXbkYaP7MEeUAPIQVJ7M5jg=", + "ZQKYe2LyHKt+tczY8BcwhLYNW0E=", + "RKPo9qOCQSz2u5Cj+BBuaJd0dsk=", + "E+3HdtMGNUn9sHAq8YLtyQWlOdQ=", + "OvCIhUu3aAZfSSnlu9+ky7BMmvk=", + ].map(bytes => ({ bytes })), + expected_emitter_chain: 26, + expected_emitter_address: "UHl0aG5ldFB5dGhuZXRQeXRobmV0UHl0aG5ldFB5dGg=", + }, ...overrides, }; } diff --git a/src/services/hermes-client/hermes-client.ts b/src/services/hermes-client/hermes-client.ts index f061cc3..e663db2 100644 --- a/src/services/hermes-client/hermes-client.ts +++ b/src/services/hermes-client/hermes-client.ts @@ -6,14 +6,15 @@ * * The Pyth contract: * 1. Receives the VAA (Verified Action Approval) from this client - * 2. Verifies VAA signatures via Wormhole contract + * 2. Verifies VAA signatures via the pyth_vaa contract * 3. Parses Pyth price attestation from VAA payload * 4. Relays validated price to x/oracle module */ import { latestValue } from "../../lib/generators/latest-value/latest-value.ts"; import { blockchainPriceStaleness, priceUpdateCounter } from "../../metrics.ts"; -import { ContractClientService, type PriceResponse, type SigningClientServiceConfig } from "../contract-client/contract-client.service.ts"; +import { ContractClientService, type ConfigResponse, type PriceResponse, type SigningClientServiceConfig } from "../contract-client/contract-client.service.ts"; +import type { RouterSetUpdater } from "../router-set-updater/router-set-updater.ts"; import type { Logger, PriceProducerFactory, PriceUpdate, PythPriceData } from "../../types.ts"; import { sanitizeErrorMessage, @@ -72,12 +73,13 @@ export interface HermesConfig { * which opens an RPC connection; inject a fake to drive the client without a chain. */ contractClientFactory?: (config: SigningClientServiceConfig) => ContractClient; + routerSetUpdater?: Pick; } /** * The chain access this client depends on. A subset of {@link ContractClientService}. */ -export type ContractClient = Pick; +export type ContractClient = Pick; export class HermesClient { readonly #signingClient: ContractClient; @@ -189,11 +191,7 @@ export class HermesClient { const config = await this.#signingClient.queryConfig(); - this.#logger.log("Submitting VAA to Pyth contract..."); - this.#logger.log(` Wormhole contract: ${config.wormhole_contract}`); - const result = await this.#signingClient.updatePrice(priceUpdate, { - updateFee: config.update_fee, - }); + const result = await this.#submitPriceUpdate(priceUpdate, config); const price = priceUpdate.priceData.price; this.#logger.log(`Price updated successfully! TX: ${result.transactionHash}`); @@ -220,6 +218,62 @@ export class HermesClient { } } + async #submitPriceUpdate(priceUpdate: PriceUpdate, config: ConfigResponse): Promise<{ + transactionHash: string; + gasUsed?: bigint; + }> { + this.#logger.log("Submitting Pyth price update..."); + this.#logger.log(` pyth_vaa contract: ${config.pyth_vaa_contract}`); + + try { + return await this.#signingClient.updatePrice(priceUpdate, { + updateFee: config.update_fee, + }); + } catch (error) { + if (!isInvalidRouterSetIndex(error)) { + throw error; + } + + const rotated = await this.#submitRouterSetUpgrade(config); + if (!rotated) { + throw error; + } + + this.#logger.log("Retrying Pyth price update after router set upgrade..."); + return await this.#signingClient.updatePrice(priceUpdate, { + updateFee: config.update_fee, + }); + } + } + + async #submitRouterSetUpgrade(config: ConfigResponse): Promise { + const routerSetUpdater = this.#config.routerSetUpdater; + if (!routerSetUpdater) { + return false; + } + + this.#logger.warn("Pyth router set is out of sync; checking for signed upgrade VAA..."); + const pythVaaConfig = await this.#signingClient.queryPythVaaConfig(config.pyth_vaa_contract); + const upgrade = await routerSetUpdater.buildUpgradeVaa(pythVaaConfig); + if (!upgrade) { + throw new Error("Pyth router set upgrade required but no signed upgrade VAA is available"); + } + + this.#logger.warn( + `Submitting pyth_vaa router set upgrade ${upgrade.currentRouterSetIndex} -> ${upgrade.newRouterSetIndex}`, + ); + const result = await this.#signingClient.submitRouterSetUpgrade({ + pythVaaContract: config.pyth_vaa_contract, + vaa: upgrade.vaa, + }); + this.#logger.log(`Router set upgraded successfully! TX: ${result.transactionHash}`); + if (result.gasUsed !== undefined) { + this.#logger.log(` Gas used: ${result.gasUsed}`); + } + + return true; + } + #canIgnorePriceUpdate(newPrice: PythPriceData, currentPrice: PriceResponse): boolean { if (newPrice.price.publish_time <= currentPrice.publish_time) { this.#logger.log( @@ -372,7 +426,7 @@ export class HermesClient { } } -export type ErrorCode = "insufficient_balance" | "timeout" | "connection_issue" | "unknown"; +export type ErrorCode = "insufficient_balance" | "router_set_out_of_sync" | "timeout" | "connection_issue" | "unknown"; export function classifyError(error: unknown): ErrorCode { const message = error instanceof Error ? error.message : ""; @@ -380,6 +434,9 @@ export function classifyError(error: unknown): ErrorCode { if (/insufficient funds|insufficient fee/i.test(message)) { return "insufficient_balance"; } + if (isInvalidRouterSetIndex(error)) { + return "router_set_out_of_sync"; + } if (/timeout|ETIMEDOUT/i.test(message)) { return "timeout"; } @@ -388,3 +445,8 @@ export function classifyError(error: unknown): ErrorCode { } return "unknown"; } + +function isInvalidRouterSetIndex(error: unknown): boolean { + const message = error instanceof Error ? error.message : ""; + return /InvalidRouterSetIndex/i.test(message); +} diff --git a/src/services/router-set-updater/fixtures/production-router-upgrades.json b/src/services/router-set-updater/fixtures/production-router-upgrades.json new file mode 100644 index 0000000..b8ae2f2 --- /dev/null +++ b/src/services/router-set-updater/fixtures/production-router-upgrades.json @@ -0,0 +1,6 @@ +[ + { + "guardianSetIndex": 1, + "vaa": "01000000000400607876fa32347973a7cf21fec253cb13afe1adcb834a14f278a2560e24184858136a6643fc1afda105067b60526d0461752ce54266a33f1b465ceadc6dd6049a010143a417b79e09c7c7366eac7199e68e3323d2fbaba84a1b0a68677d6983740dd21fdbb89d5f232849eb4374895345ed3f0a279956cb9d5eeabff16c6cff83ffe500025f1116a1eceaf55944f0664952bda5254922800daec4ce3642e96a5cd7519b692346eb805312355ea87568bccd14ade0453980fc84285e8fa3de75307593b7eb00030d3840ca2a887747049fc44cba318af3307dcf095d49a0c3717acccd3ec2757f46e2eb61c05faa5688303b88f94135acfd01e6c5eac19a7b4c6a92beb660786e0100000000000000000001000000000000000000000000000000000000000000000000000000000000000400000000000000010000000000000000000000000000000000000000000000000000000000436f7265020000000000010541534bb176e461a3fb30479400f210549ecce6386502987b62f21cab7eb5ccd8f0173084b60d5b4144a3e8f6a382412cf6bb90a3f8106e68977476c913edc776d3063549fdb0702af182edc905a539d43af088854bb768065f4929e5bbdfa4cbb04c9af9" + } +] diff --git a/src/services/router-set-updater/router-set-updater.test.ts b/src/services/router-set-updater/router-set-updater.test.ts new file mode 100644 index 0000000..ea00d5c --- /dev/null +++ b/src/services/router-set-updater/router-set-updater.test.ts @@ -0,0 +1,401 @@ +import { secp256k1 } from "@noble/curves/secp256k1"; +import { keccak_256 } from "@noble/hashes/sha3"; +import { describe, expect, it, vi } from "vitest"; + +import type { PythVaaConfigResponse } from "../contract-client/contract-client.service.ts"; +import { RouterSetUpdater } from "./router-set-updater.ts"; +import productionRouterUpgrades from "./fixtures/production-router-upgrades.json" with { type: "json" }; + +const ROUTER_COUNT = 5; +const ROUTER_ADDRESS_LEN = 20; +const CURRENT_ROUTER_SET_INDEX = 0; +const NEXT_ROUTER_SET_INDEX = 1; +const VAA_HEADER_LEN = 6; +const VAA_SIGNATURE_LEN = 66; +const VAA_BODY_PAYLOAD_POS = 51; +const GOVERNANCE_PAYLOAD_POS = 35; +const AKASH_TARGET_CHAIN = 29; +const PYTHNET_EMITTER_CHAIN = 26; +const PYTHNET_EMITTER_ADDRESS = Buffer.from("PythnetPythnetPythnetPythnetPyth"); +const GOVERNANCE_EMITTER_CHAIN = 1; +const GOVERNANCE_EMITTER_ADDRESS = Buffer.from( + "0000000000000000000000000000000000000000000000000000000000000004", + "hex", +); + +// Original set-0 addresses from akash-node/tests/e2e/pyth_contract_test.go. +const PRODUCTION_CONFIG: PythVaaConfigResponse = { + admin: "akash1admin", + governance_target_chain: AKASH_TARGET_CHAIN, + router_verifier: { + router_set_index: CURRENT_ROUTER_SET_INDEX, + routers: [ + "41534bb176e461a3fb30479400f210549ecce638", + "6502987b62f21cab7eb5ccd8f0173084b60d5b41", + "44a3e8f6a382412cf6bb90a3f8106e68977476c9", + "d9d7d4529577864352c9a6539a48238fcd447052", + "1663a5a822336ece48559b1dfb1e93a017a7dac3", + ].map(address => ({ bytes: Buffer.from(address, "hex").toString("base64") })), + expected_emitter_chain: PYTHNET_EMITTER_CHAIN, + expected_emitter_address: PYTHNET_EMITTER_ADDRESS.toString("base64"), + }, +}; + +describe(RouterSetUpdater.name, () => { + it("accepts the unchanged published production governance VAA with the configured price emitter", async () => { + // Unmodified upstream fixture, including its four original signatures: + // https://github.com/pyth-network/pyth-crosschain/blob/main/contract_manager/src/store/guardian_sets/ProCompatibleProductionGuardianSetVaas.json + const published = productionRouterUpgrades[0]; + const vaa = Buffer.from(published.vaa, "hex"); + const config = structuredClone(PRODUCTION_CONFIG); + const updater = updaterForVaa(vaa); + + const result = await updater.buildUpgradeVaa(config); + + expect(result).toEqual({ + vaa: vaa.toString("base64"), + currentRouterSetIndex: CURRENT_ROUTER_SET_INDEX, + newRouterSetIndex: published.guardianSetIndex, + signatureCount: 4, + }); + expect(result && Buffer.from(result.vaa, "base64").toString("hex")).toBe(published.vaa); + expect(config).toEqual(PRODUCTION_CONFIG); + }); + + it.each([ + { part: "signature", offset: VAA_HEADER_LEN + 1 }, + { part: "body", offset: VAA_HEADER_LEN + 4 * VAA_SIGNATURE_LEN }, + ])("rejects a tampered published VAA $part", async ({ offset }) => { + const vaa = Buffer.from(productionRouterUpgrades[0].vaa, "hex"); + vaa[offset] ^= 1; + + await expect(updaterForVaa(vaa).buildUpgradeVaa(PRODUCTION_CONFIG)).rejects.toThrow( + "signature does not match any configured router", + ); + }); + + it("rejects replay of the published VAA after the active router set advances", async () => { + const vaa = Buffer.from(productionRouterUpgrades[0].vaa, "hex"); + const config = structuredClone(PRODUCTION_CONFIG); + config.router_verifier.router_set_index = NEXT_ROUTER_SET_INDEX; + + await expect(updaterForVaa(vaa).buildUpgradeVaa(config)).rejects.toThrow( + "No router set upgrade VAA found for current router set index 1", + ); + }); + + it("fetches the documented Hermes upgrade VAA and returns base64 bytes for submit_v_a_a", async () => { + const currentKeys = routerKeys(1); + const nextKeys = routerKeys(11); + const vaa = buildRouterSetUpgradeVaa({ currentKeys, nextKeys }); + const fetch = fetchResponse({ vaa: `0x${Buffer.from(vaa).toString("hex")}` }); + const updater = new RouterSetUpdater({ + endpoint: "https://pyth.example/hermes", + authenticationToken: "secret-token", + fetch, + }); + + const result = await updater.buildUpgradeVaa(buildConfig(currentKeys)); + + expect(fetch).toHaveBeenCalledWith( + new URL("https://pyth.example/hermes/v1/guardian_set_upgrade_vaa"), + { headers: { Authorization: "Bearer secret-token" } }, + ); + expect(result).toEqual({ + vaa: Buffer.from(vaa).toString("base64"), + currentRouterSetIndex: CURRENT_ROUTER_SET_INDEX, + newRouterSetIndex: NEXT_ROUTER_SET_INDEX, + signatureCount: 3, + }); + }); + + it("returns undefined when Hermes reports no guardian set upgrade in progress", async () => { + const updater = new RouterSetUpdater({ + endpoint: "https://pyth.example/hermes", + fetch: fetchResponse("no guardian set upgrade in progress", { status: 404 }), + }); + + await expect(updater.buildUpgradeVaa(buildConfig(routerKeys(1)))).resolves.toBeUndefined(); + }); + + it("rejects an upgrade VAA for a different active router set index", async () => { + const currentKeys = routerKeys(1); + const nextKeys = routerKeys(11); + const vaa = buildRouterSetUpgradeVaa({ + currentKeys, + nextKeys, + currentRouterSetIndex: 9, + newRouterSetIndex: 10, + }); + const updater = updaterForVaa(vaa); + + await expect(updater.buildUpgradeVaa(buildConfig(currentKeys))).rejects.toThrow( + "No router set upgrade VAA found for current router set index 0", + ); + }); + + it("rejects an upgrade VAA with fewer than quorum signatures", async () => { + const currentKeys = routerKeys(1); + const nextKeys = routerKeys(11); + const vaa = buildRouterSetUpgradeVaa({ + currentKeys, + nextKeys, + signerIndexes: [0, 1], + }); + const updater = updaterForVaa(vaa); + + await expect(updater.buildUpgradeVaa(buildConfig(currentKeys))).rejects.toThrow( + "only had 2 signatures", + ); + }); + + it.each([CURRENT_ROUTER_SET_INDEX, NEXT_ROUTER_SET_INDEX + 1])( + "rejects a signed upgrade to nonsequential router set index %i", + async (newRouterSetIndex) => { + const currentKeys = routerKeys(1); + const vaa = buildRouterSetUpgradeVaa({ + currentKeys, + nextKeys: routerKeys(11), + newRouterSetIndex, + }); + + await expect(updaterForVaa(vaa).buildUpgradeVaa(buildConfig(currentKeys))).rejects.toThrow( + "No router set upgrade VAA found for current router set index 0", + ); + }, + ); + + it.each([ + { name: "duplicate", signerIndexes: [0, 0, 1] }, + { name: "out-of-order", signerIndexes: [1, 0, 2] }, + ])("rejects $name signer indices", async ({ signerIndexes }) => { + const currentKeys = routerKeys(1); + const vaa = buildRouterSetUpgradeVaa({ + currentKeys, + nextKeys: routerKeys(11), + signerIndexes, + }); + + await expect(updaterForVaa(vaa).buildUpgradeVaa(buildConfig(currentKeys))).rejects.toThrow( + "Invalid Pyth router set upgrade signature order", + ); + }); + + it("rejects signatures from routers not in pyth_vaa config", async () => { + const currentKeys = routerKeys(1); + const outsiderKeys = routerKeys(31); + const nextKeys = routerKeys(11); + const vaa = buildRouterSetUpgradeVaa({ + currentKeys: outsiderKeys, + nextKeys, + }); + const updater = updaterForVaa(vaa); + + await expect(updater.buildUpgradeVaa(buildConfig(currentKeys))).rejects.toThrow( + "does not match any configured router", + ); + }); + + it("rejects the wrong governance target chain", async () => { + const currentKeys = routerKeys(1); + const nextKeys = routerKeys(11); + const vaa = buildRouterSetUpgradeVaa({ + currentKeys, + nextKeys, + governanceTargetChain: AKASH_TARGET_CHAIN + 1, + }); + const updater = updaterForVaa(vaa); + + await expect(updater.buildUpgradeVaa(buildConfig(currentKeys))).rejects.toThrow( + "Invalid Pyth router set upgrade governance target chain", + ); + }); + + it.each([ + { + name: "configured price emitter", + emitterChain: PYTHNET_EMITTER_CHAIN, + emitterAddress: PYTHNET_EMITTER_ADDRESS, + }, + { + name: "wrong governance emitter chain", + emitterChain: GOVERNANCE_EMITTER_CHAIN + 1, + emitterAddress: GOVERNANCE_EMITTER_ADDRESS, + }, + { + name: "wrong governance emitter address", + emitterChain: GOVERNANCE_EMITTER_CHAIN, + emitterAddress: PYTHNET_EMITTER_ADDRESS, + }, + ])("rejects a quorum-signed rotation from the $name", async ({ emitterChain, emitterAddress }) => { + const currentKeys = routerKeys(1); + const nextKeys = routerKeys(11); + const vaa = buildRouterSetUpgradeVaa({ + currentKeys, + nextKeys, + emitterChain, + emitterAddress, + }); + const updater = updaterForVaa(vaa); + + await expect(updater.buildUpgradeVaa(buildConfig(currentKeys))).rejects.toThrow( + "Invalid Pyth router set upgrade emitter", + ); + }); + + it("rejects duplicate routers in the new set", async () => { + const currentKeys = routerKeys(1); + const nextKeys = routerKeys(11); + nextKeys[4] = nextKeys[3]; + const vaa = buildRouterSetUpgradeVaa({ currentKeys, nextKeys }); + const updater = updaterForVaa(vaa); + + await expect(updater.buildUpgradeVaa(buildConfig(currentKeys))).rejects.toThrow( + "duplicate router addresses", + ); + }); +}); + +function updaterForVaa(vaa: Uint8Array): RouterSetUpdater { + return new RouterSetUpdater({ + endpoint: "https://pyth.example/hermes", + fetch: fetchResponse({ vaa: Buffer.from(vaa).toString("hex") }), + }); +} + +function routerKeys(offset: number): Uint8Array[] { + return Array.from({ length: ROUTER_COUNT }, (_, index) => { + const key = new Uint8Array(32); + key.fill(index + offset); + return key; + }); +} + +function buildConfig(keys: Uint8Array[]): PythVaaConfigResponse { + return { + admin: "akash1admin", + governance_target_chain: AKASH_TARGET_CHAIN, + router_verifier: { + router_set_index: CURRENT_ROUTER_SET_INDEX, + routers: keys.map(key => ({ bytes: Buffer.from(routerAddress(key)).toString("base64") })), + expected_emitter_chain: PYTHNET_EMITTER_CHAIN, + expected_emitter_address: PYTHNET_EMITTER_ADDRESS.toString("base64"), + }, + }; +} + +function buildRouterSetUpgradeVaa(input: { + currentKeys: Uint8Array[]; + nextKeys: Uint8Array[]; + currentRouterSetIndex?: number; + newRouterSetIndex?: number; + governanceTargetChain?: number; + emitterChain?: number; + emitterAddress?: Uint8Array; + signerIndexes?: number[]; +}): Uint8Array { + const signerIndexes = input.signerIndexes ?? [0, 1, 2]; + const body = buildRouterSetUpgradeBody({ + nextKeys: input.nextKeys, + routerSetIndex: input.newRouterSetIndex ?? NEXT_ROUTER_SET_INDEX, + governanceTargetChain: input.governanceTargetChain ?? AKASH_TARGET_CHAIN, + emitterChain: input.emitterChain ?? GOVERNANCE_EMITTER_CHAIN, + emitterAddress: input.emitterAddress ?? GOVERNANCE_EMITTER_ADDRESS, + }); + const hash = keccak_256(keccak_256(body)); + const vaa = new Uint8Array(VAA_HEADER_LEN + signerIndexes.length * VAA_SIGNATURE_LEN + body.length); + let offset = 0; + vaa[offset++] = 1; + writeU32(vaa, offset, input.currentRouterSetIndex ?? CURRENT_ROUTER_SET_INDEX); + offset += 4; + vaa[offset++] = signerIndexes.length; + + for (const signerIndex of signerIndexes) { + vaa[offset++] = signerIndex; + vaa.set(signContractVaaBytes(input.currentKeys[signerIndex], hash), offset); + offset += 65; + } + + vaa.set(body, offset); + return vaa; +} + +function buildRouterSetUpgradeBody(input: { + nextKeys: Uint8Array[]; + routerSetIndex: number; + governanceTargetChain: number; + emitterChain: number; + emitterAddress: Uint8Array; +}): Uint8Array { + const body = new Uint8Array(VAA_BODY_PAYLOAD_POS + GOVERNANCE_PAYLOAD_POS + 5 + ROUTER_COUNT * ROUTER_ADDRESS_LEN); + writeU32(body, 0, 1_700_000_000); + writeU32(body, 4, 0); + writeU16(body, 8, input.emitterChain); + body.set(input.emitterAddress, 10); + writeU64(body, 42, 1n); + body[50] = 0; + + const module = Buffer.from("Core"); + body.set(module, VAA_BODY_PAYLOAD_POS + 32 - module.length); + body[VAA_BODY_PAYLOAD_POS + 32] = 2; + writeU16(body, VAA_BODY_PAYLOAD_POS + 33, input.governanceTargetChain); + + const updateStart = VAA_BODY_PAYLOAD_POS + GOVERNANCE_PAYLOAD_POS; + writeU32(body, updateStart, input.routerSetIndex); + body[updateStart + 4] = ROUTER_COUNT; + input.nextKeys.forEach((key, index) => { + body.set(routerAddress(key), updateStart + 5 + index * ROUTER_ADDRESS_LEN); + }); + + return body; +} + +function signContractVaaBytes(signingKey: Uint8Array, hash: Uint8Array): Uint8Array { + const signature = secp256k1.sign(hash, signingKey, { + prehash: false, + lowS: false, + format: "recovered", + }); + const recovery = signature.hasHighS() + ? signature.recovery ^ 1 + : signature.recovery; + const normalized = signature.normalizeS(); + return concatBytes(normalized.toBytes("compact"), Uint8Array.of(recovery)); +} + +function routerAddress(secretKey: Uint8Array): Uint8Array { + const publicKey = secp256k1.getPublicKey(secretKey, false); + return keccak_256(publicKey.subarray(1)).subarray(12); +} + +function fetchResponse(body: unknown, init: ResponseInit = {}): typeof fetch { + return vi.fn(async () => new Response( + typeof body === "string" ? body : JSON.stringify(body), + { + status: init.status ?? 200, + headers: init.headers ?? { "content-type": "application/json" }, + }, + )); +} + +function concatBytes(...parts: Uint8Array[]): Uint8Array { + const result = new Uint8Array(parts.reduce((total, part) => total + part.length, 0)); + let offset = 0; + for (const part of parts) { + result.set(part, offset); + offset += part.length; + } + return result; +} + +function writeU16(bytes: Uint8Array, offset: number, value: number): void { + new DataView(bytes.buffer, bytes.byteOffset + offset, 2).setUint16(0, value, false); +} + +function writeU32(bytes: Uint8Array, offset: number, value: number): void { + new DataView(bytes.buffer, bytes.byteOffset + offset, 4).setUint32(0, value, false); +} + +function writeU64(bytes: Uint8Array, offset: number, value: bigint): void { + new DataView(bytes.buffer, bytes.byteOffset + offset, 8).setBigUint64(0, value, false); +} diff --git a/src/services/router-set-updater/router-set-updater.ts b/src/services/router-set-updater/router-set-updater.ts new file mode 100644 index 0000000..8381b97 --- /dev/null +++ b/src/services/router-set-updater/router-set-updater.ts @@ -0,0 +1,429 @@ +import { secp256k1 } from "@noble/curves/secp256k1"; +import { keccak_256 } from "@noble/hashes/sha3"; +import { z } from "zod"; + +import type { PythVaaConfigResponse } from "../contract-client/contract-client.service.ts"; +import { validateEndpointUrl } from "../../validation.ts"; + +export const DEFAULT_HERMES_ENDPOINT = "https://pyth.dourolabs.app/hermes"; + +const ROUTER_COUNT = 5; +const ROUTER_QUORUM = 3; +const ROUTER_ADDRESS_LEN = 20; +const VAA_VERSION = 1; +const VAA_HEADER_LEN = 6; +const VAA_SIGNATURE_LEN = 66; +const VAA_EMITTER_CHAIN_POS = 8; +const VAA_EMITTER_ADDRESS_POS = 10; +const VAA_EMITTER_ADDRESS_LEN = 32; +const VAA_BODY_PAYLOAD_POS = 51; +const GOVERNANCE_MODULE_LEN = 32; +const GOVERNANCE_ACTION_POS = 32; +const GOVERNANCE_TARGET_CHAIN_POS = 33; +const GOVERNANCE_PAYLOAD_POS = 35; +const GOVERNANCE_ACTION_ROUTER_SET_UPGRADE = 2; +const GOVERNANCE_TARGET_CHAIN_GLOBAL = 0; +// Governance uses the protocol emitter, not the configured Pythnet price emitter. +// https://github.com/pyth-network/pyth-crosschain/blob/main/contract_manager/src/store/guardian_sets/ProCompatibleProductionGuardianSetVaas.json +const GOVERNANCE_EMITTER_CHAIN = 1; +const GOVERNANCE_EMITTER_ADDRESS = Buffer.from( + "0000000000000000000000000000000000000000000000000000000000000004", + "hex", +); +const SIGNATURE_LEN = 65; +const COMPACT_SIGNATURE_LEN = 64; +const U32_MAX = 0xffffffff; + +const guardianSetUpgradeVaaSchema = z.object({ + vaa: z.string(), +}).strict(); + +const pythVaaConfigSchema = z.object({ + governance_target_chain: z.number().int().min(0).max(0xffff), + router_verifier: z.object({ + router_set_index: z.number().int().min(0).max(U32_MAX), + routers: z.array(z.object({ bytes: z.string() })).length(ROUTER_COUNT), + expected_emitter_chain: z.number().int().min(0).max(0xffff), + expected_emitter_address: z.string(), + }), +}).passthrough(); + +export interface RouterSetUpdaterConfig { + endpoint: string; + authenticationToken?: string; + unsafeAllowInsecureEndpoints?: boolean; + fetch?: typeof fetch; +} + +export interface RouterSetUpgradeVaa { + vaa: string; + currentRouterSetIndex: number; + newRouterSetIndex: number; + signatureCount: number; +} + +interface RouterConfig { + routerSetIndex: number; + routerAddresses: string[]; + governanceTargetChain: number; +} + +interface RouterSignature { + routerIndex: number; +} + +interface ParsedRouterSetUpdate { + routerSetIndex: number; +} + +export class RouterSetUpdater { + readonly #endpoint: URL; + readonly #authenticationToken?: string; + readonly #fetch: typeof fetch; + + constructor(config: RouterSetUpdaterConfig) { + const onlySecureEndpoints = !(config.unsafeAllowInsecureEndpoints ?? false); + validateEndpointUrl(config.endpoint, "HERMES_ENDPOINT", onlySecureEndpoints); + this.#endpoint = withTrailingSlash(new URL(config.endpoint)); + this.#authenticationToken = config.authenticationToken; + this.#fetch = config.fetch ?? fetch; + } + + async buildUpgradeVaa(config: PythVaaConfigResponse): Promise { + const routerConfig = parsePythVaaConfig(config); + const vaa = await this.#fetchUpgradeVaa(); + if (!vaa) { + return undefined; + } + + return parseAggregateUpgradeVaa(vaa, routerConfig); + } + + async #fetchUpgradeVaa(): Promise { + const response = await this.#fetch(new URL("v1/guardian_set_upgrade_vaa", this.#endpoint), { + headers: this.#authenticationToken + ? { Authorization: `Bearer ${this.#authenticationToken}` } + : undefined, + }); + + if (response.status === 404) { + return undefined; + } + + if (!response.ok) { + throw new Error(`Pyth Hermes guardian set upgrade endpoint returned HTTP ${response.status}`); + } + + const rawBody: unknown = await response.json(); + const parsed = guardianSetUpgradeVaaSchema.safeParse(rawBody); + if (!parsed.success) { + throw new Error(`Invalid Pyth guardian set upgrade response: ${z.prettifyError(parsed.error)}`); + } + + return decodeHex(parsed.data.vaa, "vaa"); + } +} + +function parsePythVaaConfig(config: PythVaaConfigResponse): RouterConfig { + const parsed = pythVaaConfigSchema.safeParse(config); + if (!parsed.success) { + throw new Error(`Invalid pyth_vaa config: ${z.prettifyError(parsed.error)}`); + } + + const routerAddresses = parsed.data.router_verifier.routers.map((router, index) => { + const bytes = Buffer.from(router.bytes, "base64"); + if (bytes.length !== ROUTER_ADDRESS_LEN) { + throw new Error(`Invalid router address length at index ${index}`); + } + + return bytesToHex(bytes); + }); + + if (new Set(routerAddresses).size !== routerAddresses.length) { + throw new Error("Invalid pyth_vaa config: duplicate router addresses"); + } + + const expectedEmitterAddress = Buffer.from( + parsed.data.router_verifier.expected_emitter_address, + "base64", + ); + if (expectedEmitterAddress.length !== VAA_EMITTER_ADDRESS_LEN) { + throw new Error("Invalid pyth_vaa config: invalid expected emitter address length"); + } + + return { + routerSetIndex: parsed.data.router_verifier.router_set_index, + routerAddresses, + governanceTargetChain: parsed.data.governance_target_chain, + }; +} + +function parseAggregateUpgradeVaa( + vaa: Uint8Array, + routerConfig: RouterConfig, +): RouterSetUpgradeVaa { + if (vaa.length < VAA_HEADER_LEN) { + throw new Error("Invalid Pyth router set upgrade VAA length"); + } + + if (vaa[0] !== VAA_VERSION) { + throw new Error("Invalid Pyth router set upgrade VAA version"); + } + + const currentRouterSetIndex = readU32(vaa, 1); + if (currentRouterSetIndex !== routerConfig.routerSetIndex) { + throw new Error(`No router set upgrade VAA found for current router set index ${routerConfig.routerSetIndex}`); + } + + const signatureCount = vaa[5]; + if (signatureCount < ROUTER_QUORUM) { + throw new Error(`Pyth router set upgrade only had ${signatureCount} signatures; ${ROUTER_QUORUM} required`); + } + if (signatureCount > ROUTER_COUNT) { + throw new Error(`Invalid Pyth router set upgrade signature count: ${signatureCount}`); + } + + const bodyStart = VAA_HEADER_LEN + signatureCount * VAA_SIGNATURE_LEN; + if (vaa.length <= bodyStart) { + throw new Error("Invalid Pyth router set upgrade VAA body length"); + } + + const body = vaa.subarray(bodyStart); + validateEmitter(body); + validateVaaSignatures({ + body, + routerAddresses: routerConfig.routerAddresses, + signatureCount, + vaa, + }); + + const update = parseRouterSetUpdate(body, routerConfig.governanceTargetChain); + const expectedNextIndex = routerConfig.routerSetIndex + 1; + if (update.routerSetIndex !== expectedNextIndex) { + throw new Error(`No router set upgrade VAA found for current router set index ${routerConfig.routerSetIndex}`); + } + + return { + vaa: Buffer.from(vaa).toString("base64"), + currentRouterSetIndex, + newRouterSetIndex: update.routerSetIndex, + signatureCount, + }; +} + +function parseRouterSetUpdate(body: Uint8Array, governanceTargetChain: number): ParsedRouterSetUpdate { + const data = parseGovernanceRouterSetUpdate(body, governanceTargetChain); + if (data.length < 5) { + throw new Error("Invalid Pyth router set upgrade payload length"); + } + + const routerSetIndex = readU32(data, 0); + const routerCount = data[4]; + if (routerCount !== ROUTER_COUNT) { + throw new Error(`Invalid Pyth router count in upgrade body: ${routerCount}`); + } + + const expectedBodyLength = 5 + ROUTER_COUNT * ROUTER_ADDRESS_LEN; + if (data.length !== expectedBodyLength) { + throw new Error("Invalid Pyth router set upgrade payload length"); + } + + const routers = new Set(); + for (let i = 0; i < ROUTER_COUNT; i++) { + const routerStart = 5 + i * ROUTER_ADDRESS_LEN; + const router = data.subarray(routerStart, routerStart + ROUTER_ADDRESS_LEN); + const routerHex = bytesToHex(router); + if (routers.has(routerHex)) { + throw new Error("Invalid Pyth router set upgrade: duplicate router addresses"); + } + routers.add(routerHex); + } + + return { routerSetIndex }; +} + +function parseGovernanceRouterSetUpdate(body: Uint8Array, governanceTargetChain: number): Uint8Array { + if (body.length < VAA_BODY_PAYLOAD_POS + GOVERNANCE_PAYLOAD_POS) { + throw new Error("Invalid Pyth router set upgrade body length"); + } + + const module = Buffer.from( + body.subarray( + VAA_BODY_PAYLOAD_POS, + VAA_BODY_PAYLOAD_POS + GOVERNANCE_MODULE_LEN, + ), + ).toString("utf8").replaceAll("\0", ""); + if (module !== "Core") { + throw new Error("Invalid Pyth router set upgrade governance module"); + } + + const action = body[VAA_BODY_PAYLOAD_POS + GOVERNANCE_ACTION_POS]; + if (action !== GOVERNANCE_ACTION_ROUTER_SET_UPGRADE) { + throw new Error("Invalid Pyth router set upgrade governance action"); + } + + const targetChain = readU16(body, VAA_BODY_PAYLOAD_POS + GOVERNANCE_TARGET_CHAIN_POS); + if (targetChain !== GOVERNANCE_TARGET_CHAIN_GLOBAL && targetChain !== governanceTargetChain) { + throw new Error("Invalid Pyth router set upgrade governance target chain"); + } + + return body.subarray(VAA_BODY_PAYLOAD_POS + GOVERNANCE_PAYLOAD_POS); +} + +function validateEmitter(body: Uint8Array): void { + if (body.length < VAA_BODY_PAYLOAD_POS) { + throw new Error("Invalid Pyth router set upgrade VAA body length"); + } + + const emitterChain = readU16(body, VAA_EMITTER_CHAIN_POS); + const emitterAddress = body.subarray( + VAA_EMITTER_ADDRESS_POS, + VAA_EMITTER_ADDRESS_POS + VAA_EMITTER_ADDRESS_LEN, + ); + if ( + emitterChain !== GOVERNANCE_EMITTER_CHAIN + || !bytesEqual(emitterAddress, GOVERNANCE_EMITTER_ADDRESS) + ) { + throw new Error("Invalid Pyth router set upgrade emitter"); + } +} + +function validateVaaSignatures(input: { + vaa: Uint8Array; + signatureCount: number; + body: Uint8Array; + routerAddresses: string[]; +}): void { + let lastRouterIndex = -1; + const seen = new Set(); + + for (let i = 0; i < input.signatureCount; i++) { + const signatureStart = VAA_HEADER_LEN + i * VAA_SIGNATURE_LEN; + const routerIndex = input.vaa[signatureStart]; + if (routerIndex <= lastRouterIndex) { + throw new Error("Invalid Pyth router set upgrade signature order"); + } + if (routerIndex >= input.routerAddresses.length) { + throw new Error(`Invalid Pyth router set upgrade router index: ${routerIndex}`); + } + if (seen.has(routerIndex)) { + throw new Error("Invalid Pyth router set upgrade duplicate signature index"); + } + + const recovered = recoverRouterSignature({ + body: input.body, + signature: input.vaa.subarray(signatureStart + 1, signatureStart + VAA_SIGNATURE_LEN), + routerAddresses: input.routerAddresses, + }); + if (recovered.routerIndex !== routerIndex) { + throw new Error("Pyth router set upgrade signature does not match claimed router index"); + } + + seen.add(routerIndex); + lastRouterIndex = routerIndex; + } +} + +function recoverRouterSignature(input: { + body: Uint8Array; + signature: Uint8Array; + routerAddresses: string[]; +}): RouterSignature { + if (input.signature.length !== SIGNATURE_LEN) { + throw new Error(`Invalid Pyth router set upgrade signature length: ${input.signature.length}`); + } + + const hash = keccak_256(keccak_256(input.body)); + const candidates = signatureCandidates(input.signature); + + for (const candidate of candidates) { + try { + const signature = secp256k1.Signature.fromBytes(candidate.recoveredSignature, "recovered"); + const recoveredKey = signature.recoverPublicKey(hash).toBytes(false); + const recoveredRouter = bytesToHex(keccak_256(recoveredKey.subarray(1)).subarray(12)); + const routerIndex = input.routerAddresses.indexOf(recoveredRouter); + if (routerIndex >= 0) { + return { routerIndex }; + } + } catch { + continue; + } + } + + throw new Error("Pyth router set upgrade signature does not match any configured router"); +} + +function signatureCandidates(signature: Uint8Array): Array<{ + recoveredSignature: Uint8Array; +}> { + const rsvRecoveryId = normalizeRecoveryId(signature[COMPACT_SIGNATURE_LEN]); + const vrsRecoveryId = normalizeRecoveryId(signature[0]); + const candidates: Array<{ + recoveredSignature: Uint8Array; + }> = []; + + if (rsvRecoveryId !== undefined) { + const compact = signature.subarray(0, COMPACT_SIGNATURE_LEN); + candidates.push({ + recoveredSignature: concatBytes(Uint8Array.of(rsvRecoveryId), compact), + }); + } + + if (vrsRecoveryId !== undefined) { + const compact = signature.subarray(1); + candidates.push({ + recoveredSignature: concatBytes(Uint8Array.of(vrsRecoveryId), compact), + }); + } + + return candidates; +} + +function decodeHex(value: string, fieldName: string): Uint8Array { + const normalized = value.startsWith("0x") ? value.slice(2) : value; + if (!/^(?:[0-9a-fA-F]{2})+$/.test(normalized)) { + throw new Error(`Invalid Pyth router set upgrade ${fieldName}: expected hex bytes`); + } + + return Buffer.from(normalized, "hex"); +} + +function bytesToHex(bytes: Uint8Array): string { + return Buffer.from(bytes).toString("hex"); +} + +function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} + +function concatBytes(...parts: Uint8Array[]): Uint8Array { + const result = new Uint8Array(parts.reduce((total, part) => total + part.length, 0)); + let offset = 0; + for (const part of parts) { + result.set(part, offset); + offset += part.length; + } + return result; +} + +function readU16(bytes: Uint8Array, offset: number): number { + return new DataView(bytes.buffer, bytes.byteOffset + offset, 2).getUint16(0, false); +} + +function readU32(bytes: Uint8Array, offset: number): number { + return new DataView(bytes.buffer, bytes.byteOffset + offset, 4).getUint32(0, false); +} + +function normalizeRecoveryId(value: number): number | undefined { + if (value === 0 || value === 1) return value; + if (value === 27 || value === 28) return value - 27; + return undefined; +} + +function withTrailingSlash(url: URL): URL { + return new URL(url.href.endsWith("/") ? url.href : `${url.href}/`); +} diff --git a/src/types.ts b/src/types.ts index e8ec9a0..7531290 100644 --- a/src/types.ts +++ b/src/types.ts @@ -17,6 +17,7 @@ export interface PythPriceData { export interface PriceUpdate { priceData: PythPriceData; + // PNAU update data from the upgraded Pyth Hermes API. vaa: string; } @@ -51,11 +52,14 @@ export interface PriceUpdateOptions { export interface UpdatePriceFeedMsg { update_price_feed: { - // VAA data from Pyth Hermes API (base64 encoded Binary) - // The Pyth contract will: - // 1. Verify VAA via Wormhole contract - // 2. Parse Pyth price attestation from payload - // 3. Relay to x/oracle module + // PNAU data from Pyth Hermes API (base64 encoded Binary). + vaa: string; + }; +} + +export interface SubmitVaaMsg { + submit_v_a_a: { + // Router-set upgrade VAA (base64 encoded Binary). vaa: string; }; } diff --git a/src/validation.test.ts b/src/validation.test.ts index e1b76f9..6ddcd54 100644 --- a/src/validation.test.ts +++ b/src/validation.test.ts @@ -15,7 +15,7 @@ import { describe("SEC-02: validateEndpointUrl", () => { it("accepts valid HTTPS URLs", () => { expect(validateEndpointUrl("https://rpc.akashnet.net:443", "rpc")).toBe("https://rpc.akashnet.net:443"); - expect(validateEndpointUrl("https://hermes.pyth.network", "hermes")).toBe("https://hermes.pyth.network"); + expect(validateEndpointUrl("https://pyth.dourolabs.app/hermes", "hermes")).toBe("https://pyth.dourolabs.app/hermes"); }); it("rejects HTTP URLs (non-encrypted)", () => {