diff --git a/package-lock.json b/package-lock.json index 2093e7b..fdcb9ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.2", + "@faker-js/faker": "^10.5.0", "@stylistic/eslint-plugin": "^5.8.0", "@types/node": "^24.0.0", "@vitest/coverage-v8": "^4.0.18", @@ -842,6 +843,23 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@faker-js/faker": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-10.5.0.tgz", + "integrity": "sha512-bsxD8WLS5lIj7aaoCx1YJkktqYj5vlBUE6HWzu2Q51ksrGJ0H737ECCKlFU7Yf8Br45z9t99frBp/J7kzbMPAg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/fakerjs" + } + ], + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0", + "npm": ">=10" + } + }, "node_modules/@grpc/grpc-js": { "version": "1.14.3", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", diff --git a/package.json b/package.json index 431cdf9..ecc246a 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.2", + "@faker-js/faker": "^10.5.0", "@stylistic/eslint-plugin": "^5.8.0", "@types/node": "^24.0.0", "@vitest/coverage-v8": "^4.0.18", diff --git a/src/cli-commands/admin-refresh-params.test.ts b/src/cli-commands/admin-refresh-params/admin-refresh-params.test.ts similarity index 79% rename from src/cli-commands/admin-refresh-params.test.ts rename to src/cli-commands/admin-refresh-params/admin-refresh-params.test.ts index 72a5c4c..0e1d8b4 100644 --- a/src/cli-commands/admin-refresh-params.test.ts +++ b/src/cli-commands/admin-refresh-params/admin-refresh-params.test.ts @@ -1,21 +1,19 @@ -import EventEmitter from "node:events"; import { describe, it, expect, vi } from "vitest"; import { mock } from "vitest-mock-extended"; -import type { HermesClient } from "../hermes-client.ts"; +import type { ContractClientService } from "../services/contract-client/contract-client.service.ts"; import type { CommandConfig } from "./command-config.ts"; import { adminRefreshParams } from "./admin-refresh-params.ts"; function setup() { - const client = mock(); + const client = mock(); const logger = mock(); - const config: CommandConfig = { + const config = { rpcEndpoint: "https://rpc.akashnet.net:443", contractAddress: "akash1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5lzv7xu", - mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + walletSecret: { type: "mnemonic", value: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" }, logger, - process: new EventEmitter(), - createHermesClient: vi.fn(() => Promise.resolve(client)), - }; + createContractClient: vi.fn(() => client), + } as unknown as CommandConfig; return { config, client, logger }; } diff --git a/src/cli-commands/admin-refresh-params.ts b/src/cli-commands/admin-refresh-params/admin-refresh-params.ts similarity index 72% rename from src/cli-commands/admin-refresh-params.ts rename to src/cli-commands/admin-refresh-params/admin-refresh-params.ts index 62847de..bcd5415 100644 --- a/src/cli-commands/admin-refresh-params.ts +++ b/src/cli-commands/admin-refresh-params/admin-refresh-params.ts @@ -1,9 +1,9 @@ -import type { CommandConfig } from "./command-config.ts"; +import type { CommandConfig } from "../command-config.ts"; export async function adminRefreshParams(config: CommandConfig): Promise { config.logger?.log("Refreshing oracle parameters...\n"); - const client = await config.createHermesClient(config); + const client = config.createContractClient(config); const txHash = await client.refreshOracleParams(); config.logger?.log("Oracle params refreshed successfully!"); config.logger?.log(`TX: ${txHash}`); diff --git a/src/cli-commands/admin-transfer.test.ts b/src/cli-commands/admin-transfer/admin-transfer.test.ts similarity index 79% rename from src/cli-commands/admin-transfer.test.ts rename to src/cli-commands/admin-transfer/admin-transfer.test.ts index 26b9a37..a054ce4 100644 --- a/src/cli-commands/admin-transfer.test.ts +++ b/src/cli-commands/admin-transfer/admin-transfer.test.ts @@ -1,21 +1,19 @@ -import EventEmitter from "node:events"; import { describe, it, expect, vi } from "vitest"; import { mock } from "vitest-mock-extended"; -import type { HermesClient } from "../hermes-client.ts"; +import type { ContractClientService } from "../services/contract-client/contract-client.service.ts"; import type { CommandConfig } from "./command-config.ts"; import { adminTransfer } from "./admin-transfer.ts"; function setup() { - const client = mock(); + const client = mock(); const logger = mock(); - const config: CommandConfig = { + const config = { rpcEndpoint: "https://rpc.akashnet.net:443", contractAddress: "akash1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5lzv7xu", - mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + walletSecret: { type: "mnemonic", value: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" }, logger, - process: new EventEmitter(), - createHermesClient: vi.fn(() => Promise.resolve(client)), - }; + createContractClient: vi.fn(() => client), + } as unknown as CommandConfig; return { config, client, logger }; } @@ -46,14 +44,14 @@ describe("adminTransfer", () => { const { config } = setup(); await expect(adminTransfer(config, "not-valid")).rejects.toThrow("Invalid address format"); - expect(config.createHermesClient).not.toHaveBeenCalled(); + expect(config.createContractClient).not.toHaveBeenCalled(); }); it("rejects empty address before connecting", async () => { const { config } = setup(); await expect(adminTransfer(config, "")).rejects.toThrow("Invalid address format"); - expect(config.createHermesClient).not.toHaveBeenCalled(); + expect(config.createContractClient).not.toHaveBeenCalled(); }); it("propagates errors from transferAdmin", async () => { diff --git a/src/cli-commands/admin-transfer.ts b/src/cli-commands/admin-transfer/admin-transfer.ts similarity index 69% rename from src/cli-commands/admin-transfer.ts rename to src/cli-commands/admin-transfer/admin-transfer.ts index faed7ee..86ba1c9 100644 --- a/src/cli-commands/admin-transfer.ts +++ b/src/cli-commands/admin-transfer/admin-transfer.ts @@ -1,12 +1,12 @@ -import { validateAkashAddress } from "../validation.ts"; -import type { CommandConfig } from "./command-config.ts"; +import { validateAkashAddress } from "../../validation.ts"; +import type { CommandConfig } from "../command-config.ts"; export async function adminTransfer(config: CommandConfig, newAdmin: string): Promise { // SEC-05: Validate address format at CLI boundary validateAkashAddress(newAdmin); config.logger?.log(`Transferring admin to ${newAdmin}...\n`); - const client = await config.createHermesClient(config); + const client = config.createContractClient(config); const txHash = await client.transferAdmin(newAdmin); config.logger?.log("Admin transferred successfully!"); config.logger?.log(`TX: ${txHash}`); diff --git a/src/cli-commands/admin-update-fee.test.ts b/src/cli-commands/admin-update-fee/admin-update-fee.test.ts similarity index 74% rename from src/cli-commands/admin-update-fee.test.ts rename to src/cli-commands/admin-update-fee/admin-update-fee.test.ts index 216a366..73e36f4 100644 --- a/src/cli-commands/admin-update-fee.test.ts +++ b/src/cli-commands/admin-update-fee/admin-update-fee.test.ts @@ -1,21 +1,19 @@ -import EventEmitter from "node:events"; import { describe, it, expect, vi } from "vitest"; import { mock } from "vitest-mock-extended"; -import type { HermesClient } from "../hermes-client.ts"; +import type { ContractClientService } from "../services/contract-client/contract-client.service.ts"; import type { CommandConfig } from "./command-config.ts"; import { adminUpdateFee } from "./admin-update-fee.ts"; function setup() { - const client = mock(); + const client = mock(); const logger = mock(); - const config: CommandConfig = { + const config = { rpcEndpoint: "https://rpc.akashnet.net:443", contractAddress: "akash1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5lzv7xu", - mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + walletSecret: { type: "mnemonic", value: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" }, logger, - process: new EventEmitter(), - createHermesClient: vi.fn(() => Promise.resolve(client)), - }; + createContractClient: vi.fn(() => client), + } as unknown as CommandConfig; return { config, client, logger }; } @@ -44,20 +42,20 @@ describe("adminUpdateFee", () => { const { config } = setup(); await expect(adminUpdateFee(config, "abc")).rejects.toThrow("Invalid fee"); - expect(config.createHermesClient).not.toHaveBeenCalled(); + expect(config.createContractClient).not.toHaveBeenCalled(); }); it("rejects negative fee before connecting", async () => { const { config } = setup(); await expect(adminUpdateFee(config, "-100")).rejects.toThrow("Invalid fee"); - expect(config.createHermesClient).not.toHaveBeenCalled(); + expect(config.createContractClient).not.toHaveBeenCalled(); }); it("rejects decimal fee before connecting", async () => { const { config } = setup(); await expect(adminUpdateFee(config, "100.5")).rejects.toThrow("Invalid fee"); - expect(config.createHermesClient).not.toHaveBeenCalled(); + expect(config.createContractClient).not.toHaveBeenCalled(); }); }); diff --git a/src/cli-commands/admin-update-fee.ts b/src/cli-commands/admin-update-fee/admin-update-fee.ts similarity index 68% rename from src/cli-commands/admin-update-fee.ts rename to src/cli-commands/admin-update-fee/admin-update-fee.ts index f301321..14f956a 100644 --- a/src/cli-commands/admin-update-fee.ts +++ b/src/cli-commands/admin-update-fee/admin-update-fee.ts @@ -1,12 +1,12 @@ -import { validateFeeAmount } from "../validation.ts"; -import type { CommandConfig } from "./command-config.ts"; +import { validateFeeAmount } from "../../validation.ts"; +import type { CommandConfig } from "../command-config.ts"; export async function adminUpdateFee(config: CommandConfig, newFee: string): Promise { // SEC-06: Validate fee format at CLI boundary validateFeeAmount(newFee); config.logger?.log(`Updating fee to ${newFee}...\n`); - const client = await config.createHermesClient(config); + const client = config.createContractClient(config); const txHash = await client.updateFee(newFee); config.logger?.log("Fee updated successfully!"); config.logger?.log(`TX: ${txHash}`); diff --git a/src/cli-commands/command-config.ts b/src/cli-commands/command-config.ts index 0f59f35..bddf772 100644 --- a/src/cli-commands/command-config.ts +++ b/src/cli-commands/command-config.ts @@ -1,14 +1,14 @@ import { z } from "zod"; -import { HermesClient, type HermesConfig } from "../hermes-client.ts"; +import { HermesClient, type HermesConfig } from "../services/hermes-client/hermes-client.ts"; import { validateContractAddress, validateWalletSecret } from "../validation.ts"; 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 { PriceUpdateOrdered } from "../price-update/price-update-ordered/price-update-ordered.ts"; -import { PriceUpdateUnordered } from "../price-update/price-update-unordered/price-update-unordered.ts"; +import { ContractClientService, type SigningClientServiceConfig } from "../services/contract-client/contract-client.service.ts"; export interface CommandConfig extends HermesConfig { - createHermesClient: (config: HermesConfig) => Promise; + createHermesClient: (config: HermesConfig) => HermesClient; + createContractClient: (config: SigningClientServiceConfig) => ContractClientService; signal: AbortSignal; healthcheckPort: number; insufficientBalanceRetryDelayMs: number; @@ -48,9 +48,11 @@ const configSchema = z.object({ }).optional(), PRICE_FETCHING_METHOD: z.enum(["polling", "sse"]).default("polling"), PRICE_UPDATE_TX_METHOD: z.enum(["ordered", "unordered"]).default("ordered"), + UNORDERED_TX_TTL_MS: z.coerce.number().int().min(1000).positive().default(180_000), UPDATE_INTERVAL_MS: z.coerce.number().int().nonnegative().default(5 * 1000), // Default to 5 seconds HEALTHCHECK_PORT: z.coerce.number().int().min(1).max(65535).default(3000), GAS_PRICE: z.string().regex(/^(\d+)(\.\d+)?uakt$/, { message: 'GAS_PRICE must be a valid number with unit (e.g., "0.025uakt")' }).default("0.025uakt"), + GAS_MULTIPLIER: z.coerce.number().min(1).default(1.5), DENOM: z.string().default("uakt"), NODE_ENV: z.enum(["development", "production"]).optional(), SMART_CONTRACT_CONFIG_CACHE_TTL_MS: z.coerce.number().int().min(1000).positive().default(60 * 60 * 1000), @@ -75,10 +77,13 @@ export function parseConfig(config: Record): ParseCo walletSecret: result.data.WALLET_SECRET, healthcheckPort: result.data.HEALTHCHECK_PORT, gasPrice: result.data.GAS_PRICE, + gasMultiplier: result.data.GAS_MULTIPLIER, denom: result.data.DENOM, priceDeviationTolerance: result.data.PRICE_DEVIATION_TOLERANCE, smartContractConfigCacheTTLMs: result.data.SMART_CONTRACT_CONFIG_CACHE_TTL_MS, insufficientBalanceRetryDelayMs: result.data.INSUFFICIENT_BALANCE_RETRY_DELAY_MS, + priceUpdateTxMethod: result.data.PRICE_UPDATE_TX_METHOD, + unorderedTxTtlMs: result.data.UNORDERED_TX_TTL_MS, priceProducerFactory(options: PriceProducerFactoryOptions) { if (result.data.PRICE_FETCHING_METHOD === "sse") { return priceSSEStream({ @@ -96,13 +101,8 @@ export function parseConfig(config: Record): ParseCo authenticationToken: result.data.HERMES_API_KEY, }); }, - priceUpdaterFactory(client, signer) { - if (result.data.PRICE_UPDATE_TX_METHOD === "unordered") { - return new PriceUpdateUnordered(client, signer); - } - return new PriceUpdateOrdered(client); - }, - createHermesClient: (cfg: HermesConfig) => HermesClient.connect(cfg), + createHermesClient: (cfg: HermesConfig) => new HermesClient(cfg), + createContractClient: (cfg: SigningClientServiceConfig) => new ContractClientService(cfg), }; return { ok: true, value: parsedConfig }; diff --git a/src/cli-commands/daemon-command.test.ts b/src/cli-commands/daemon-command/daemon-command.test.ts similarity index 94% rename from src/cli-commands/daemon-command.test.ts rename to src/cli-commands/daemon-command/daemon-command.test.ts index 3eb7015..cfc7344 100644 --- a/src/cli-commands/daemon-command.test.ts +++ b/src/cli-commands/daemon-command/daemon-command.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, afterEach } from "vitest"; import { mock } from "vitest-mock-extended"; -import type { HermesClient } from "../hermes-client.ts"; -import type { CommandConfig } from "./command-config.ts"; +import type { HermesClient } from "../../services/hermes-client/hermes-client.ts"; +import type { CommandConfig } from "../command-config.ts"; import { daemonCommand } from "./daemon-command.ts"; describe("daemonCommand", () => { @@ -105,7 +105,7 @@ describe("daemonCommand", () => { const logger = mock(); const abortController = new AbortController(); testAbortController = abortController; - const config: CommandConfig = { + const config = { rpcEndpoint: "https://rpc.akashnet.net:443", contractAddress: "akash1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5lzv7xu", walletSecret: { type: "mnemonic", value: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" }, @@ -113,10 +113,10 @@ describe("daemonCommand", () => { logger, signal: abortController.signal, healthcheckPort: 0, - createHermesClient: vi.fn(() => Promise.resolve(client)), + createHermesClient: vi.fn(() => client), smartContractConfigCacheTTLMs: 0, rawConfig: {} as CommandConfig["rawConfig"], - }; + } as unknown as CommandConfig; return { config, client, logger, abortController }; } }); diff --git a/src/cli-commands/daemon-command.ts b/src/cli-commands/daemon-command/daemon-command.ts similarity index 92% rename from src/cli-commands/daemon-command.ts rename to src/cli-commands/daemon-command/daemon-command.ts index 919f5f4..f65a745 100644 --- a/src/cli-commands/daemon-command.ts +++ b/src/cli-commands/daemon-command/daemon-command.ts @@ -1,7 +1,7 @@ import http from "node:http"; import type { AddressInfo } from "node:net"; -import { prometheusExporter } from "../instrumentation/prometheus-exporter.ts"; -import type { CommandConfig } from "./command-config.ts"; +import { prometheusExporter } from "../../instrumentation/prometheus-exporter.ts"; +import type { CommandConfig } from "../command-config.ts"; export async function daemonCommand(config: CommandConfig): Promise { if (config.signal.aborted) return; diff --git a/src/cli-commands/query-command.test.ts b/src/cli-commands/query-command/query-command.test.ts similarity index 93% rename from src/cli-commands/query-command.test.ts rename to src/cli-commands/query-command/query-command.test.ts index f544ba6..2b6998a 100644 --- a/src/cli-commands/query-command.test.ts +++ b/src/cli-commands/query-command/query-command.test.ts @@ -1,21 +1,19 @@ -import EventEmitter from "node:events"; import { describe, it, expect, vi } from "vitest"; import { mock } from "vitest-mock-extended"; -import type { HermesClient } from "../hermes-client.ts"; +import type { ContractClientService } from "../services/contract-client/contract-client.service.ts"; import type { CommandConfig } from "./command-config.ts"; import { queryCommand } from "./query-command.ts"; function setup() { - const client = mock(); + const client = mock(); const logger = mock(); - const config: CommandConfig = { + const config = { rpcEndpoint: "https://rpc.akashnet.net:443", contractAddress: "akash1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5lzv7xu", - mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + walletSecret: { type: "mnemonic", value: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" }, logger, - process: new EventEmitter(), - createHermesClient: vi.fn(() => Promise.resolve(client)), - }; + createContractClient: vi.fn(() => client), + } as unknown as CommandConfig; return { config, client, logger }; } diff --git a/src/cli-commands/query-command.ts b/src/cli-commands/query-command/query-command.ts similarity index 97% rename from src/cli-commands/query-command.ts rename to src/cli-commands/query-command/query-command.ts index 8438cdf..9efd9e3 100644 --- a/src/cli-commands/query-command.ts +++ b/src/cli-commands/query-command/query-command.ts @@ -1,4 +1,4 @@ -import type { CommandConfig } from "./command-config.ts"; +import type { CommandConfig } from "../command-config.ts"; export interface QueryOptions { feed?: boolean; @@ -7,7 +7,7 @@ export interface QueryOptions { } export async function queryCommand(config: CommandConfig, options: QueryOptions): Promise { - const client = await config.createHermesClient(config); + const client = config.createContractClient(config); if (options.config) { config.logger?.log("Contract Configuration:\n"); diff --git a/src/cli-commands/status-command.test.ts b/src/cli-commands/status-command/status-command.test.ts similarity index 93% rename from src/cli-commands/status-command.test.ts rename to src/cli-commands/status-command/status-command.test.ts index cad12a0..cd934e2 100644 --- a/src/cli-commands/status-command.test.ts +++ b/src/cli-commands/status-command/status-command.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi } from "vitest"; import { mock } from "vitest-mock-extended"; -import type { HermesClient } from "../hermes-client.ts"; -import type { CommandConfig } from "./command-config.ts"; +import type { HermesClient } from "../../services/hermes-client/hermes-client.ts"; +import type { CommandConfig } from "../command-config.ts"; import { statusCommand } from "./status-command.ts"; function setup() { @@ -14,7 +14,7 @@ function setup() { HERMES_ENDPOINT: "https://hermes.pyth.network", }, logger, - createHermesClient: vi.fn(() => Promise.resolve(client)), + createHermesClient: vi.fn(() => client), } as unknown as CommandConfig; return { config, client, logger }; } diff --git a/src/cli-commands/status-command.ts b/src/cli-commands/status-command/status-command.ts similarity index 86% rename from src/cli-commands/status-command.ts rename to src/cli-commands/status-command/status-command.ts index 3470038..0e1ca35 100644 --- a/src/cli-commands/status-command.ts +++ b/src/cli-commands/status-command/status-command.ts @@ -1,9 +1,9 @@ -import type { CommandConfig } from "./command-config.ts"; +import type { CommandConfig } from "../command-config.ts"; export async function statusCommand(config: CommandConfig): Promise { config.logger?.log("Contract Status...\n"); - const client = await config.createHermesClient(config); + const client = config.createHermesClient(config); const status = await client.getStatus(); config.logger?.log("Client Status:"); diff --git a/src/cli-commands/update-command.test.ts b/src/cli-commands/update-command/update-command.test.ts similarity index 82% rename from src/cli-commands/update-command.test.ts rename to src/cli-commands/update-command/update-command.test.ts index d9e1c71..021e1c4 100644 --- a/src/cli-commands/update-command.test.ts +++ b/src/cli-commands/update-command/update-command.test.ts @@ -6,17 +6,8 @@ import { updateCommand } from "./update-command.ts"; function setup() { const client = mock(); - client.queryConfig.mockResolvedValue({ - admin: "akash1admin", - wormhole_contract: "akash1wormhole", - update_fee: "1", - price_feed_id: "test-feed-id", - default_denom: "uakt", - default_base_denom: "uakt", - data_sources: [], - }); const logger = mock(); - const config: CommandConfig = { + const config = { rpcEndpoint: "https://rpc.akashnet.net:443", contractAddress: "akash1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5lzv7xu", walletSecret: { type: "mnemonic", value: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" }, @@ -26,8 +17,8 @@ function setup() { rawConfig: {} as CommandConfig["rawConfig"], smartContractConfigCacheTTLMs: 60000, priceProducerFactory: vi.fn(), - createHermesClient: vi.fn(() => Promise.resolve(client)), - }; + createHermesClient: vi.fn(() => client), + } as unknown as CommandConfig; return { config, client, logger }; } diff --git a/src/cli-commands/update-command.ts b/src/cli-commands/update-command/update-command.ts similarity index 61% rename from src/cli-commands/update-command.ts rename to src/cli-commands/update-command/update-command.ts index 162da7b..93134a2 100644 --- a/src/cli-commands/update-command.ts +++ b/src/cli-commands/update-command/update-command.ts @@ -1,7 +1,7 @@ -import type { CommandConfig } from "./command-config.ts"; +import type { CommandConfig } from "../command-config.ts"; export async function updateCommand(config: CommandConfig): Promise { config.logger?.log("Updating oracle price...\n"); - const client = await config.createHermesClient(config); + const client = config.createHermesClient(config); await client.updatePrice({ signal: config.signal }); } diff --git a/src/cli.ts b/src/cli.ts index e4480af..d319a89 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -18,13 +18,13 @@ import process from "node:process"; import { program } from "commander"; import { createCommandBuilder } from "./cli-commands/create-command-builder.ts"; -import { updateCommand } from "./cli-commands/update-command.ts"; -import { queryCommand } from "./cli-commands/query-command.ts"; -import { statusCommand } from "./cli-commands/status-command.ts"; -import { daemonCommand } from "./cli-commands/daemon-command.ts"; -import { adminRefreshParams } from "./cli-commands/admin-refresh-params.ts"; -import { adminUpdateFee } from "./cli-commands/admin-update-fee.ts"; -import { adminTransfer } from "./cli-commands/admin-transfer.ts"; +import { updateCommand } from "./cli-commands/update-command/update-command.ts"; +import { queryCommand } from "./cli-commands/query-command/query-command.ts"; +import { statusCommand } from "./cli-commands/status-command/status-command.ts"; +import { daemonCommand } from "./cli-commands/daemon-command/daemon-command.ts"; +import { adminRefreshParams } from "./cli-commands/admin-refresh-params/admin-refresh-params.ts"; +import { adminUpdateFee } from "./cli-commands/admin-update-fee/admin-update-fee.ts"; +import { adminTransfer } from "./cli-commands/admin-transfer/admin-transfer.ts"; const command = createCommandBuilder({ process, console }); diff --git a/src/hermes-client.test.ts b/src/hermes-client.test.ts deleted file mode 100644 index 5560322..0000000 --- a/src/hermes-client.test.ts +++ /dev/null @@ -1,1086 +0,0 @@ -import { SigningCosmWasmClient } from "@cosmjs/cosmwasm-stargate"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { mock } from "vitest-mock-extended"; -import { HermesClient, HermesConfig, classifyError } from "./hermes-client"; -import { blockchainPriceStaleness, priceUpdateCounter } from "./metrics.ts"; -import { PriceUpdateOrdered } from "./price-update/price-update-ordered/price-update-ordered.ts"; -import type { PriceUpdate, PriceProducerFactory, PriceProducerFactoryOptions } from "./types.ts"; - -// ============================================================ -// SEC-01: Mnemonic must never appear in logs or error messages -// ============================================================ -describe("SEC-01: Mnemonic leakage prevention", () => { - const SECRET_MNEMONIC = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; - - it("initialize() error must not contain mnemonic", async () => { - const { client, logger, stargateClient } = setup({ - rpcEndpoint: "https://invalid-host-that-will-fail.example.com:443", - walletSecret: { type: "mnemonic", value: SECRET_MNEMONIC }, - }); - - stargateClient.queryContractSmart.mockRejectedValueOnce(new Error(`${SECRET_MNEMONIC} is not valid`)); - const result = await client.initialize().catch(error => ({ error })); - - expect(result).toHaveProperty("error"); - const { error } = result as { error: Error }; - expect(error.message).not.toContain(SECRET_MNEMONIC); - expect(error.message).not.toContain(SECRET_MNEMONIC.split(" ")[0]); // Check for partial mnemonic - - // Check all console output - for (const call of logger.log.mock.calls.concat(logger.error.mock.calls, logger.warn.mock.calls)) { - const output = call.join(" "); - expect(output).not.toContain(SECRET_MNEMONIC); - } - }); -}); - -// ============================================================ -// SEC-02: URL validation must be enforced on endpoints -// ============================================================ -describe("SEC-02: Endpoint URL validation in HermesClient", () => { - it("rejects HTTP RPC endpoints", () => { - expect(() => setup({ - rpcEndpoint: "http://insecure-rpc.example.com", - })).toThrow("only HTTPS endpoints are allowed"); - }); - - it("rejects SSRF-targeted RPC endpoints (localhost)", () => { - expect(() => setup({ - rpcEndpoint: "https://localhost:26657", - })).toThrow("private or internal addresses are not allowed"); - }); - - it("accepts valid HTTPS endpoints", () => { - const { client } = setup(); - expect(client).toBeDefined(); - }); -}); - -// ============================================================ -// SEC-04: Error messages must not leak implementation details -// ============================================================ -describe("SEC-04: Error message information leakage", () => { - it("updatePrice errors do not leak internal paths or stack traces", async () => { - const { client, priceUpdate } = setup(); - - // Without initializing, calling updatePrice should fail gracefully - const result = await client.updatePrice(priceUpdate).catch(error => ({ error })); - - expect(result).toHaveProperty("error"); - const { error } = result as { error: Error }; - expect(error.message).not.toMatch(/\/[^\s]+\.(ts|js)/); - expect(error.message).not.toContain("at "); - expect(error.message).not.toContain("node_modules"); - }); -}); - -// ============================================================ -// SEC-05: Admin operations must validate inputs -// ============================================================ -describe("SEC-05: Admin input validation", () => { - it("transferAdmin rejects invalid address format", async () => { - const { client } = setup(); - await expect(client.transferAdmin("not-a-valid-address")) - .rejects.toThrow("Invalid address format"); - }); - - it("transferAdmin rejects empty address", async () => { - const { client } = setup(); - await expect(client.transferAdmin("")) - .rejects.toThrow("Invalid address format"); - }); - - it("updateFee rejects non-numeric fee", async () => { - const { client } = setup(); - await expect(client.updateFee("abc")) - .rejects.toThrow("Invalid fee"); - }); - - it("updateFee rejects negative fee", async () => { - const { client } = setup(); - await expect(client.updateFee("-100")) - .rejects.toThrow("Invalid fee"); - }); - - it("updateFee rejects decimal fee", async () => { - const { client } = setup(); - await expect(client.updateFee("100.5")) - .rejects.toThrow("Invalid fee"); - }); -}); - -// ============================================================ -// SEC-08: Config/status must not expose sensitive data -// ============================================================ -describe("SEC-08: Sensitive data in config exposure", () => { - it("getStatus must not include mnemonic, gasPrice, or internal config", async () => { - const { client } = setup(); - - await expect(client.getStatus()).rejects.toThrow("Client not initialized"); - }); - - it("getStatus must not include config object or mnemonic when initialized", async () => { - const { client } = setup(); - - await client.initialize(); - const status = await client.getStatus(); - - expect(status).toEqual({ - isRunning: false, - contractAddress: "akash1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5lzv7xu", - priceFeedId: "test-feed-id", - address: expect.stringMatching(/^akash1[0-9a-z]{38}$/), - lastPriceUpdateReceivedAt: undefined, - }); - expect(JSON.stringify(status)).not.toContain("abandon"); - }); -}); - -describe(HermesClient.name, () => { - describe("constructor", () => { - it("allows HTTP endpoints when unsafeAllowInsecureEndpoints is true", () => { - const { client } = setup({ - rpcEndpoint: "http://rpc.akashnet.net", - unsafeAllowInsecureEndpoints: true, - }); - expect(client).toBeDefined(); - }); - - it("allows private addresses when unsafeAllowInsecureEndpoints is true", () => { - const { client } = setup({ - rpcEndpoint: "http://localhost:26657", - unsafeAllowInsecureEndpoints: true, - }); - expect(client).toBeDefined(); - }); - - it("rejects invalid mnemonic word count", () => { - expect(() => setup({ - walletSecret: { type: "mnemonic", value: "abandon abandon abandon" }, - })).toThrow("Invalid mnemonic"); - }); - - it("rejects mnemonic with invalid characters", () => { - expect(() => setup({ - walletSecret: { type: "mnemonic", value: "Abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon About" }, - })).toThrow("Invalid mnemonic"); - }); - - it("accepts a valid private key", () => { - const { client } = setup({ - walletSecret: { type: "privateKey", value: "0000000000000000000000000000000000000000000000000000000000000001" }, - }); - expect(client).toBeDefined(); - }); - - it("rejects an invalid private key", () => { - expect(() => setup({ - walletSecret: { type: "privateKey", value: "not-a-valid-hex-key" }, - })).toThrow("Invalid private key"); - }); - }); - - describe("initialize()", () => { - it("creates wallet, connects to chain, and fetches price feed ID", async () => { - const { client, stargateClient } = setup(); - - await client.initialize(); - - expect(stargateClient.queryContractSmart).toHaveBeenCalledWith( - "akash1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5lzv7xu", - { get_config: {} }, - ); - const status = await client.getStatus(); - expect(status.priceFeedId).toBe("test-feed-id"); - expect(status.address).toMatch(/^akash1[0-9a-z]{38}$/); - }); - - it("creates wallet from private key, connects to chain, and fetches price feed ID", async () => { - const { client, stargateClient } = setup({ - walletSecret: { type: "privateKey", value: "0000000000000000000000000000000000000000000000000000000000000001" }, - }); - - await client.initialize(); - - expect(stargateClient.queryContractSmart).toHaveBeenCalledWith( - "akash1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5lzv7xu", - { get_config: {} }, - ); - const status = await client.getStatus(); - expect(status.priceFeedId).toBe("test-feed-id"); - expect(status.address).toMatch(/^akash1[0-9a-z]{38}$/); - }); - - it("sanitizes and rethrows when initialization fails", async () => { - const { client, stargateClient } = setup(); - stargateClient.queryContractSmart.mockRejectedValueOnce( - new Error("connection refused at /internal/path.ts"), - ); - - const error = await client.initialize().catch(e => e); - - expect(error.message).toContain("Failed to initialize Hermes client"); - expect(error.message).not.toContain("/internal/path.ts"); - }); - }); - - describe("updatePrice()", () => { - it("throws when client is not initialized", async () => { - const { client, priceUpdate } = setup(); - await expect(client.updatePrice(priceUpdate)).rejects.toThrow("Client not initialized"); - }); - - it("submits VAA when price is stale", async () => { - const { client, priceUpdate, stargateClient } = setup(); - - stargateClient.queryContractSmart - .mockResolvedValueOnce({ price_feed_id: "test-feed-id", update_fee: "1", wormhole_contract: "akash1wormhole", admin: "akash1admin", default_denom: "uakt", default_base_denom: "akt", data_sources: [] }) - .mockResolvedValueOnce({ price: "12345", conf: "10", expo: -8, publish_time: 1234567880 }); - stargateClient.execute.mockResolvedValueOnce({ - transactionHash: "ABCD1234", - gasUsed: 500000n, - gasWanted: 600000n, - height: 100, - events: [], - logs: [], - }); - - await client.initialize(); - await client.updatePrice(priceUpdate); - - expect(stargateClient.execute).toHaveBeenCalledWith( - expect.stringMatching(/^akash1/), - "akash1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5lzv7xu", - { update_price_feed: { vaa: btoa("some base 64 endcodable data") } }, - "auto", - undefined, - [{ denom: "uakt", amount: "1" }], - ); - }); - - it("skips update when price is already up to date", async () => { - const { client, priceUpdate, stargateClient, logger } = setup(); - - stargateClient.queryContractSmart - .mockResolvedValueOnce({ price_feed_id: "test-feed-id" }) - .mockResolvedValueOnce({ publish_time: 1234567890 }); - - await client.initialize(); - await client.updatePrice(priceUpdate); - - expect(stargateClient.execute).not.toHaveBeenCalled(); - expect(logger.log).toHaveBeenCalledWith( - expect.stringContaining("already up to date"), - ); - }); - - it("skips update when contract has newer publish_time", async () => { - const { client, priceUpdate, stargateClient } = setup(); - - stargateClient.queryContractSmart - .mockResolvedValueOnce({ price_feed_id: "test-feed-id" }) - .mockResolvedValueOnce({ publish_time: 9999999999 }); - - await client.initialize(); - await client.updatePrice(priceUpdate); - - expect(stargateClient.execute).not.toHaveBeenCalled(); - }); - - describe("priceDeviationTolerance", () => { - it("skips update when absolute deviation is within tolerance", async () => { - const { client, priceUpdate, stargateClient, logger } = setup({ - priceDeviationTolerance: { type: "absolute", value: 1.0 }, - priceFeed: buildPriceFeed("10000", -2, 2000), - }); - mockForSkip(stargateClient, { price: "10050", expo: -2, publish_time: 1000 }); - - await client.initialize(); - await client.updatePrice(priceUpdate); - - expect(stargateClient.execute).not.toHaveBeenCalled(); - expect(logger.log).toHaveBeenCalledWith( - expect.stringContaining("absolute tolerance"), - ); - }); - - it("updates when absolute deviation exceeds tolerance", async () => { - const { client, priceUpdate, stargateClient } = setup({ - priceDeviationTolerance: { type: "absolute", value: 1.0 }, - priceFeed: buildPriceFeed("10000", -2, 2000), - }); - mockForUpdate(stargateClient, { price: "10200", expo: -2, publish_time: 1000 }); - - await client.initialize(); - await client.updatePrice(priceUpdate); - - expect(stargateClient.execute).toHaveBeenCalledTimes(1); - }); - - it("skips update when absolute deviation equals tolerance exactly", async () => { - const { client, priceUpdate, stargateClient, logger } = setup({ - priceDeviationTolerance: { type: "absolute", value: 1.0 }, - priceFeed: buildPriceFeed("10000", -2, 2000), - }); - mockForSkip(stargateClient, { price: "10100", expo: -2, publish_time: 1000 }); - - await client.initialize(); - await client.updatePrice(priceUpdate); - - expect(stargateClient.execute).not.toHaveBeenCalled(); - expect(logger.log).toHaveBeenCalledWith( - expect.stringContaining("absolute tolerance"), - ); - }); - - it("skips update when percentage deviation is within tolerance", async () => { - const { client, priceUpdate, stargateClient, logger } = setup({ - priceDeviationTolerance: { type: "percentage", value: 1 }, - priceFeed: buildPriceFeed("10000", -2, 2000), - }); - mockForSkip(stargateClient, { price: "10050", expo: -2, publish_time: 1000 }); - - await client.initialize(); - await client.updatePrice(priceUpdate); - - expect(stargateClient.execute).not.toHaveBeenCalled(); - expect(logger.log).toHaveBeenCalledWith( - expect.stringContaining("percentage tolerance"), - ); - }); - - it("updates when percentage deviation exceeds tolerance", async () => { - const { client, priceUpdate, stargateClient } = setup({ - priceDeviationTolerance: { type: "percentage", value: 1 }, - priceFeed: buildPriceFeed("10000", -2, 2000), - }); - mockForUpdate(stargateClient, { price: "10500", expo: -2, publish_time: 1000 }); - - await client.initialize(); - await client.updatePrice(priceUpdate); - - expect(stargateClient.execute).toHaveBeenCalledTimes(1); - }); - - it("skips update when percentage deviation equals tolerance exactly", async () => { - const { client, priceUpdate, stargateClient, logger } = setup({ - priceDeviationTolerance: { type: "percentage", value: 1 }, - priceFeed: buildPriceFeed("10100", -2, 2000), - }); - mockForSkip(stargateClient, { price: "10000", expo: -2, publish_time: 1000 }); - - await client.initialize(); - await client.updatePrice(priceUpdate); - - expect(stargateClient.execute).not.toHaveBeenCalled(); - expect(logger.log).toHaveBeenCalledWith( - expect.stringContaining("percentage tolerance"), - ); - }); - - it("updates on any price difference with default tolerance (absolute 0)", async () => { - const { client, priceUpdate, stargateClient } = setup({ - priceFeed: buildPriceFeed("10001", -2, 2000), - }); - mockForUpdate(stargateClient, { price: "10000", expo: -2, publish_time: 1000 }); - - await client.initialize(); - await client.updatePrice(priceUpdate); - - expect(stargateClient.execute).toHaveBeenCalledTimes(1); - }); - - it("handles different exponents between new and current price", async () => { - const { client, priceUpdate, stargateClient } = setup({ - priceDeviationTolerance: { type: "absolute", value: 1.0 }, - priceFeed: buildPriceFeed("1000000", -4, 2000), - }); - mockForUpdate(stargateClient, { price: "10200", expo: -2, publish_time: 1000 }); - - await client.initialize(); - await client.updatePrice(priceUpdate); - - expect(stargateClient.execute).toHaveBeenCalledTimes(1); - }); - - it("handles zero current price when calculating percentage deviation", async () => { - const { client, priceUpdate, stargateClient } = setup({ - priceDeviationTolerance: { type: "percentage", value: 10 }, - priceFeed: buildPriceFeed("10000", -2, 2000), - }); - mockForUpdate(stargateClient, { price: "0", expo: -2, publish_time: 1000 }); - - await client.initialize(); - await client.updatePrice(priceUpdate); - - expect(stargateClient.execute).toHaveBeenCalledTimes(1); - }); - }); - - it("records price staleness on successful update", async () => { - const stalenessSpy = vi.spyOn(blockchainPriceStaleness, "record"); - const { client, priceUpdate, stargateClient } = setup({ - priceFeed: buildPriceFeed("10000", -2, 2000), - }); - mockForUpdate(stargateClient, { price: "9000", expo: -2, publish_time: 1000 }); - - await client.initialize(); - await client.updatePrice(priceUpdate); - - expect(stalenessSpy).toHaveBeenCalledWith(1000); - stalenessSpy.mockRestore(); - }); - - it("records price staleness on skipped update", async () => { - const stalenessSpy = vi.spyOn(blockchainPriceStaleness, "record"); - const { client, priceUpdate, stargateClient } = setup({ - priceFeed: buildPriceFeed("10000", -2, 2000), - }); - mockForSkip(stargateClient, { price: "10000", expo: -2, publish_time: 2000 }); - - await client.initialize(); - await client.updatePrice(priceUpdate); - - expect(stalenessSpy).toHaveBeenCalledWith(0); - stalenessSpy.mockRestore(); - }); - - it("records error_code attribute and staleness on failure", async () => { - const counterSpy = vi.spyOn(priceUpdateCounter, "add"); - const stalenessSpy = vi.spyOn(blockchainPriceStaleness, "record"); - const { client, stargateClient } = setup({ - priceFeed: buildPriceFeed("10000", -2, 2000), - }); - - stargateClient.queryContractSmart - .mockResolvedValueOnce({ price_feed_id: "test-feed-id", update_fee: "1", wormhole_contract: "akash1wormhole", admin: "akash1admin", default_denom: "uakt", default_base_denom: "akt", data_sources: [] }) - .mockResolvedValueOnce({ price: "9000", conf: "10", expo: -2, publish_time: 1000 }); - stargateClient.execute.mockRejectedValueOnce(new Error("insufficient funds")); - - await client.initialize(); - await client.updatePrice(buildPriceFeed("10000", -2, 2000)).catch(() => {}); - - expect(counterSpy).toHaveBeenCalledWith(1, { result: "failure", error_code: "insufficient_balance" }); - expect(stalenessSpy).toHaveBeenCalledWith(1000); - counterSpy.mockRestore(); - stalenessSpy.mockRestore(); - }); - - function mockForUpdate(stargateClient: ReturnType["stargateClient"], currentPrice: { price: string; expo: number; publish_time: number }) { - stargateClient.queryContractSmart - .mockResolvedValueOnce({ price_feed_id: "test-feed-id", update_fee: "1", wormhole_contract: "akash1wormhole", admin: "akash1admin", default_denom: "uakt", default_base_denom: "akt", data_sources: [] }) - .mockResolvedValueOnce({ price: currentPrice.price, conf: "10", expo: currentPrice.expo, publish_time: currentPrice.publish_time }); - stargateClient.execute.mockResolvedValueOnce({ - transactionHash: "TX_DEV", - gasUsed: 500000n, - gasWanted: 600000n, - height: 100, - events: [], - logs: [], - }); - } - - function mockForSkip(stargateClient: ReturnType["stargateClient"], currentPrice: { price: string; expo: number; publish_time: number }) { - stargateClient.queryContractSmart - .mockResolvedValueOnce({ price_feed_id: "test-feed-id" }) - .mockResolvedValueOnce({ price: currentPrice.price, conf: "10", expo: currentPrice.expo, publish_time: currentPrice.publish_time }); - } - }); - - describe("queryCurrentPrice()", () => { - it("throws when client is not initialized", async () => { - const { client } = setup(); - await expect(client.queryCurrentPrice()).rejects.toThrow("Client not initialized"); - }); - - it("queries contract for current price", async () => { - const { client, stargateClient } = setup(); - const expectedPrice = { price: "12345", conf: "10", expo: -8, publish_time: 1000 }; - - stargateClient.queryContractSmart - .mockResolvedValueOnce({ price_feed_id: "test-feed-id" }) - .mockResolvedValueOnce(expectedPrice); - - await client.initialize(); - const result = await client.queryCurrentPrice(); - - expect(result).toEqual(expectedPrice); - expect(stargateClient.queryContractSmart).toHaveBeenLastCalledWith( - "akash1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5lzv7xu", - { get_price: {} }, - ); - }); - }); - - describe("queryPriceFeed()", () => { - it("throws when client is not initialized", async () => { - const { client } = setup(); - await expect(client.queryPriceFeed()).rejects.toThrow("Client not initialized"); - }); - - it("queries contract for price feed", async () => { - const { client, stargateClient } = setup(); - const expectedFeed = { - symbol: "AKT/USD", - price: "12345", - conf: "10", - expo: -8, - publish_time: 1000, - prev_publish_time: 900, - }; - - stargateClient.queryContractSmart - .mockResolvedValueOnce({ price_feed_id: "test-feed-id" }) - .mockResolvedValueOnce(expectedFeed); - - await client.initialize(); - const result = await client.queryPriceFeed(); - - expect(result).toEqual(expectedFeed); - expect(stargateClient.queryContractSmart).toHaveBeenLastCalledWith( - "akash1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5lzv7xu", - { get_price_feed: {} }, - ); - }); - }); - - describe("queryConfig()", () => { - it("queries contract for config", async () => { - const { client, stargateClient } = setup({ smartContractConfigCacheTTLMs: -1 }); - const expectedConfig = { - admin: "akash1admin", - wormhole_contract: "akash1wormhole", - update_fee: "100", - price_feed_id: "test-feed-id", - default_denom: "uakt", - default_base_denom: "akt", - data_sources: [], - }; - - stargateClient.queryContractSmart - .mockResolvedValueOnce({ price_feed_id: "test-feed-id", update_fee: "1", wormhole_contract: "akash1wormhole", admin: "akash1admin", default_denom: "uakt", default_base_denom: "akt", data_sources: [] }) - .mockResolvedValueOnce(expectedConfig); - - await client.initialize(); - const result = await client.queryConfig(); - - expect(result).toEqual(expectedConfig); - expect(stargateClient.queryContractSmart).toHaveBeenLastCalledWith( - "akash1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5lzv7xu", - { get_config: {} }, - ); - }); - }); - - describe("queryOracleParams()", () => { - it("queries contract for oracle params", async () => { - const { client, stargateClient } = setup(); - const expectedParams = { - max_price_deviation_bps: 500, - min_price_sources: 1, - max_price_staleness_blocks: 100, - twap_window: 300, - last_updated_height: 50, - }; - - stargateClient.queryContractSmart - .mockResolvedValueOnce({ price_feed_id: "test-feed-id" }) - .mockResolvedValueOnce(expectedParams); - - await client.initialize(); - const result = await client.queryOracleParams(); - - expect(result).toEqual(expectedParams); - expect(stargateClient.queryContractSmart).toHaveBeenLastCalledWith( - "akash1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5lzv7xu", - { get_oracle_params: {} }, - ); - }); - }); - - describe("refreshOracleParams()", () => { - it("throws when client is not initialized", async () => { - const { client } = setup(); - await expect(client.refreshOracleParams()).rejects.toThrow("Client not initialized"); - }); - - it("executes refresh_oracle_params on contract and returns tx hash", async () => { - const { client, stargateClient } = setup(); - stargateClient.execute.mockResolvedValueOnce({ - transactionHash: "TX_REFRESH", - gasUsed: 100000n, - gasWanted: 150000n, - height: 200, - events: [], - logs: [], - }); - - await client.initialize(); - const txHash = await client.refreshOracleParams(); - - expect(txHash).toBe("TX_REFRESH"); - expect(stargateClient.execute).toHaveBeenCalledWith( - expect.stringMatching(/^akash1/), - "akash1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5lzv7xu", - { refresh_oracle_params: {} }, - "auto", - ); - }); - }); - - describe("updateFee()", () => { - it("throws when client is not initialized", async () => { - const { client } = setup(); - await expect(client.updateFee("100")).rejects.toThrow("Client not initialized"); - }); - - it("executes update_fee on contract and returns tx hash", async () => { - const { client, stargateClient } = setup(); - stargateClient.execute.mockResolvedValueOnce({ - transactionHash: "TX_FEE", - gasUsed: 100000n, - gasWanted: 150000n, - height: 200, - events: [], - logs: [], - }); - - await client.initialize(); - const txHash = await client.updateFee("500"); - - expect(txHash).toBe("TX_FEE"); - expect(stargateClient.execute).toHaveBeenCalledWith( - expect.stringMatching(/^akash1/), - "akash1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5lzv7xu", - { update_fee: { new_fee: "500" } }, - "auto", - ); - }); - }); - - describe("transferAdmin()", () => { - it("throws when client is not initialized", async () => { - const { client } = setup(); - await expect( - client.transferAdmin("akash1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5lzv7xu"), - ).rejects.toThrow("Client not initialized"); - }); - - it("executes transfer_admin on contract and returns tx hash", async () => { - const { client, stargateClient } = setup(); - const newAdmin = "akash1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5lzv7xu"; - stargateClient.execute.mockResolvedValueOnce({ - transactionHash: "TX_ADMIN", - gasUsed: 100000n, - gasWanted: 150000n, - height: 200, - events: [], - logs: [], - }); - - await client.initialize(); - const txHash = await client.transferAdmin(newAdmin); - - expect(txHash).toBe("TX_ADMIN"); - expect(stargateClient.execute).toHaveBeenCalledWith( - expect.stringMatching(/^akash1/), - "akash1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5lzv7xu", - { transfer_admin: { new_admin: newAdmin } }, - "auto", - ); - }); - }); - - describe("start()", () => { - afterEach(() => { - vi.useRealTimers(); - }); - - it("starts once if called concurrently", async () => { - const priceUpdate = buildPriceFeed("123.45", -8, 1234567890); - const factory = blockingFactory(priceUpdate); - const { client, stargateClient } = setup({ priceProducerFactory: factory }); - const start = client.start.bind(client); - const abortController = new AbortController(); - - stargateClient.queryContractSmart - .mockResolvedValueOnce({ price_feed_id: "test-feed-id", update_fee: "1", wormhole_contract: "akash1wormhole", admin: "akash1admin", default_denom: "uakt", default_base_denom: "akt", data_sources: [] }) - .mockResolvedValueOnce({ price: "12345", conf: "10", expo: -8, publish_time: 1234567880 }); - stargateClient.execute.mockResolvedValueOnce({ - transactionHash: "TX_CONCURRENT", - gasUsed: 500000n, - gasWanted: 600000n, - height: 100, - events: [], - logs: [], - }); - - const allPromise = Promise.all([ - start({ signal: abortController.signal }), - start({ signal: abortController.signal }), - start({ signal: abortController.signal }), - start({ signal: abortController.signal }), - ]); - - await vi.waitFor(() => { - expect(stargateClient.execute).toHaveBeenCalledTimes(1); - }); - - abortController.abort(); - await allPromise; - - expect(factory).toHaveBeenCalledTimes(1); - }); - - it("logs and returns when already running", async () => { - const priceUpdate = buildPriceFeed("123.45", -8, 1234567890); - const factory = blockingFactory(priceUpdate); - const { client, logger, stargateClient } = setup({ priceProducerFactory: factory }); - const abortController = new AbortController(); - - stargateClient.queryContractSmart - .mockResolvedValueOnce({ price_feed_id: "test-feed-id" }) - .mockResolvedValueOnce({ publish_time: 1234567890 }); - - const startPromise = client.start({ signal: abortController.signal }); - await vi.waitFor(async () => { - expect((await client.getStatus()).isRunning).toBe(true); - }); - await client.start({ signal: abortController.signal }); - - expect(logger.log).toHaveBeenCalledWith("Hermes client is already running"); - - abortController.abort(); - await startPromise; - }); - - it("initializes client when not already initialized", async () => { - const { client, stargateClient } = setup(); - - stargateClient.queryContractSmart - .mockResolvedValueOnce({ price_feed_id: "test-feed-id" }); - - const abortController = new AbortController(); - await client.start({ signal: abortController.signal }); - abortController.abort(); - - const status = await client.getStatus(); - expect(status.priceFeedId).toBe("test-feed-id"); - expect(status.address).toMatch(/^akash1/); - }); - - it("skips initialization when already initialized", async () => { - const { client, stargateClient } = setup(); - - stargateClient.queryContractSmart - .mockResolvedValueOnce({ price_feed_id: "test-feed-id" }) - .mockResolvedValueOnce({ publish_time: 1234567890 }); - - await client.initialize(); - const abortController = new AbortController(); - await client.start({ signal: abortController.signal }); - abortController.abort(); - - expect(stargateClient.queryContractSmart).toHaveBeenCalledTimes(2); - }); - - it("stops when abort signal fires", async () => { - const priceUpdate = buildPriceFeed("123.45", -8, 1234567890); - const factory = blockingFactory(priceUpdate); - const { client, stargateClient, logger } = setup({ priceProducerFactory: factory }); - const ac = new AbortController(); - - stargateClient.queryContractSmart - .mockResolvedValueOnce({ price_feed_id: "test-feed-id" }) - .mockResolvedValueOnce({ publish_time: 1234567890 }); - - const startPromise = client.start({ signal: ac.signal }); - await vi.waitFor(async () => { - expect((await client.getStatus()).isRunning).toBe(true); - }); - - ac.abort(); - await startPromise; - - expect((await client.getStatus()).isRunning).toBe(false); - expect(logger.log).toHaveBeenCalledWith("Hermes client stopped"); - }); - - it("continues running when updatePrice throws", async () => { - const { client, stargateClient, logger } = setup(); - const abortController = new AbortController(); - - stargateClient.queryContractSmart - .mockResolvedValueOnce({ price_feed_id: "test-feed-id" }) - .mockRejectedValueOnce(new Error("query failed")); - - await client.start({ signal: abortController.signal }); - abortController.abort(); - - expect(logger.error).toHaveBeenCalledWith( - "Error in scheduled update:", - expect.any(Error), - ); - }); - - it("rejects when initialization fails", async () => { - const { client, stargateClient } = setup(); - - stargateClient.queryContractSmart.mockRejectedValueOnce( - new Error("connection refused"), - ); - - const ac = new AbortController(); - try { - await expect(client.start({ signal: ac.signal })).rejects.toThrow("Failed to start Hermes client"); - } finally { - ac.abort(); - } - }); - - it("sets lastPriceUpdateReceivedAt in ISO-8601 format after receiving a price update", async () => { - const priceUpdate = buildPriceFeed("123.45", -8, 1234567890); - const factory = blockingFactory(priceUpdate); - const { client, stargateClient } = setup({ priceProducerFactory: factory }); - const ac = new AbortController(); - - stargateClient.queryContractSmart - .mockResolvedValueOnce({ price_feed_id: "test-feed-id", update_fee: "1", wormhole_contract: "akash1wormhole", admin: "akash1admin", default_denom: "uakt", default_base_denom: "akt", data_sources: [] }) - .mockResolvedValueOnce({ price: "12345", conf: "10", expo: -8, publish_time: 1234567880 }); - stargateClient.execute.mockResolvedValueOnce({ - transactionHash: "TX_TS", - gasUsed: 500000n, - gasWanted: 600000n, - height: 100, - events: [], - logs: [], - }); - - const startPromise = client.start({ signal: ac.signal }); - await vi.waitFor(async () => { - const status = await client.getStatus(); - expect(status.lastPriceUpdateReceivedAt).toBeDefined(); - }); - - const status = await client.getStatus(); - expect(status.lastPriceUpdateReceivedAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/); - - ac.abort(); - await startPromise; - }); - - it("lastPriceUpdateReceivedAt is undefined before any price update is received", async () => { - const { client } = setup(); - await client.initialize(); - - const status = await client.getStatus(); - expect(status.lastPriceUpdateReceivedAt).toBeUndefined(); - }); - - it("drops updates that arrive while a price update is in flight, keeping the latest", async () => { - const priceUpdate1 = buildPriceFeed("10000", -2, 2000, "vaa-1"); - const priceUpdate2 = buildPriceFeed("10100", -2, 3000, "vaa-2"); - const priceUpdate3 = buildPriceFeed("10200", -2, 4000, "vaa-3"); - // Hold the first on-chain update open so updates 2 and 3 both arrive while it is in flight. - const { promise: firstUpdateInFlight, resolve: releaseFirstUpdate } = Promise.withResolvers(); - const { promise: firstUpdateStarted, resolve: firstUpdateReached } = Promise.withResolvers(); - const factory = vi.fn(async function* () { - yield priceUpdate1; - await firstUpdateStarted; - yield priceUpdate2; - yield priceUpdate3; - releaseFirstUpdate(); - }); - const { client, stargateClient } = setup({ priceProducerFactory: factory as unknown as PriceProducerFactory }); - - stargateClient.queryContractSmart - .mockResolvedValueOnce({ price_feed_id: "test-feed-id", update_fee: "1", wormhole_contract: "akash1wormhole", admin: "akash1admin", default_denom: "uakt", default_base_denom: "akt", data_sources: [] }) - .mockResolvedValueOnce({ price: "9000", conf: "10", expo: -2, publish_time: 1000 }); - const receipt = { - transactionHash: "TX", - gasUsed: 500000n, - gasWanted: 600000n, - height: 100, - events: [], - logs: [], - }; - stargateClient.execute - .mockImplementationOnce(async () => { - firstUpdateReached(); - await firstUpdateInFlight; - return receipt; - }) - .mockResolvedValue(receipt); - - const ac = new AbortController(); - await client.start({ signal: ac.signal }); - ac.abort(); - - const submittedVaas = stargateClient.execute.mock.calls.map((call) => (call[2] as { update_price_feed: { vaa: string } }).update_price_feed.vaa); - expect(submittedVaas).toEqual([btoa("vaa-1"), btoa("vaa-3")]); - }); - - it("enters cooldown on insufficient balance error and retries after delay", async () => { - vi.useFakeTimers(); - // Use resolvers to control when each price update is delivered - const { promise: secondUpdateReady, resolve: releaseSecondUpdate } = Promise.withResolvers(); - const factory = vi.fn(async function* ({ signal }: PriceProducerFactoryOptions) { - // First update: will trigger insufficient funds - yield buildPriceFeed("10000", -2, 2000); - // Wait until test signals to release the second update (after cooldown) - await secondUpdateReady; - // Second update: will succeed after cooldown - yield buildPriceFeed("10200", -2, 4000); - if (signal && !signal.aborted) { - await new Promise(resolve => { - signal.addEventListener("abort", () => resolve(), { once: true }); - }); - } - }); - const { client, stargateClient, logger } = setup({ - priceProducerFactory: factory as unknown as PriceProducerFactory, - insufficientBalanceRetryDelayMs: 5000, - }); - - // queryConfig (from start()) - stargateClient.queryContractSmart - .mockResolvedValueOnce({ price_feed_id: "test-feed-id", update_fee: "1", wormhole_contract: "akash1wormhole", admin: "akash1admin", default_denom: "uakt", default_base_denom: "akt", data_sources: [] }); - // First update attempt: queryCurrentPrice then execute fails with insufficient funds - stargateClient.queryContractSmart - .mockResolvedValueOnce({ price: "9000", conf: "10", expo: -2, publish_time: 1000 }); - stargateClient.execute.mockRejectedValueOnce(new Error("insufficient funds")); - - // Second update (after cooldown): queryCurrentPrice then execute succeeds - stargateClient.queryContractSmart - .mockResolvedValueOnce({ price: "9000", conf: "10", expo: -2, publish_time: 1000 }); - stargateClient.execute.mockResolvedValueOnce({ - transactionHash: "TX_RECOVERY", - gasUsed: 500000n, - gasWanted: 600000n, - height: 100, - events: [], - logs: [], - }); - - const ac = new AbortController(); - const startPromise = client.start({ signal: ac.signal }); - - // Wait for the cooldown warning to appear - await vi.waitFor(() => { - expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining("insufficient balance"), - ); - }); - - // Advance time past the cooldown and release the next update - await vi.advanceTimersByTimeAsync(5000); - releaseSecondUpdate(); - - // Wait for the recovery attempt to succeed - await vi.waitFor(() => { - expect(stargateClient.execute).toHaveBeenCalledTimes(2); - }); - - ac.abort(); - await startPromise; - }); - }); - - describe("classifyError()", () => { - it('returns "insufficient_balance" for insufficient funds error', () => { - expect(classifyError(new Error("insufficient funds: 100uakt < 1000uakt"))).toBe("insufficient_balance"); - }); - - it('returns "insufficient_balance" for insufficient fee error', () => { - expect(classifyError(new Error("insufficient fee"))).toBe("insufficient_balance"); - }); - - it('returns "timeout" for timeout error', () => { - expect(classifyError(new Error("request timeout"))).toBe("timeout"); - }); - - it('returns "timeout" for ETIMEDOUT error', () => { - expect(classifyError(new Error("connect ETIMEDOUT 1.2.3.4:443"))).toBe("timeout"); - }); - - it('returns "connection_issue" for ECONNREFUSED error', () => { - expect(classifyError(new Error("connect ECONNREFUSED 127.0.0.1:26657"))).toBe("connection_issue"); - }); - - it('returns "connection_issue" for ECONNRESET error', () => { - expect(classifyError(new Error("read ECONNRESET"))).toBe("connection_issue"); - }); - - it('returns "connection_issue" for ENOTFOUND error', () => { - expect(classifyError(new Error("getaddrinfo ENOTFOUND rpc.example.com"))).toBe("connection_issue"); - }); - - it('returns "unknown" for unrecognized errors', () => { - expect(classifyError(new Error("something unexpected"))).toBe("unknown"); - }); - - it('returns "unknown" for non-Error values', () => { - expect(classifyError("string error")).toBe("unknown"); - }); - }); -}); - -function setup(input?: Partial & { - priceFeed?: PriceUpdate; -}) { - const priceUpdate: PriceUpdate = input?.priceFeed ?? { - priceData: { - id: "test-id", - price: { price: "123.45", conf: "0.01", expo: -8, publish_time: 1234567890 }, - ema_price: { price: "123.45", conf: "0.01", expo: -8, publish_time: 1234567890 }, - }, - vaa: btoa("some base 64 endcodable data"), - }; - - const priceProducerFactory = vi.fn(async function* () { - yield priceUpdate; - }); - - const stargateClient = mock({ - queryContractSmart: vi.fn(async () => ({ price_feed_id: "test-feed-id", update_fee: "1", wormhole_contract: "akash1wormhole", admin: "akash1admin", default_denom: "uakt", default_base_denom: "akt", data_sources: [] })), - }); - const logger = mock(); - const client = new HermesClient({ - rpcEndpoint: input?.rpcEndpoint ?? "https://rpc.akashnet.net:443", - contractAddress: input?.contractAddress ?? "akash1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5lzv7xu", - walletSecret: input?.walletSecret ?? { type: "mnemonic", value: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" }, - gasPrice: input?.gasPrice ?? "0.025uakt", - connectWithSigner: async () => stargateClient, - logger, - unsafeAllowInsecureEndpoints: input?.unsafeAllowInsecureEndpoints, - priceDeviationTolerance: input?.priceDeviationTolerance ?? { type: "absolute", value: 0 }, - priceProducerFactory: (input?.priceProducerFactory ?? priceProducerFactory) as PriceProducerFactory, - priceUpdaterFactory: input?.priceUpdaterFactory ?? (client => new PriceUpdateOrdered(client)), - smartContractConfigCacheTTLMs: input?.smartContractConfigCacheTTLMs ?? 60_000, - insufficientBalanceRetryDelayMs: input?.insufficientBalanceRetryDelayMs, - }); - - return { client, priceUpdate, priceProducerFactory, logger, stargateClient }; -} - -function buildPriceFeed(price: string, expo: number, publishTime: number, vaa = "vaa-data"): PriceUpdate { - return { - priceData: { - id: "test-id", - price: { price, conf: "10", expo, publish_time: publishTime }, - ema_price: { price, conf: "10", expo, publish_time: publishTime }, - }, - vaa: btoa(vaa), - }; -} - -function blockingFactory(priceUpdate: PriceUpdate) { - return vi.fn(async function* ({ signal }: PriceProducerFactoryOptions) { - yield priceUpdate; - if (signal && !signal.aborted) { - await new Promise(resolve => { - signal.addEventListener("abort", () => resolve(), { once: true }); - }); - } - }); -} diff --git a/src/lib/caching/helpers/helpers.test.ts b/src/lib/caching/helpers/helpers.test.ts new file mode 100644 index 0000000..e0d5d75 --- /dev/null +++ b/src/lib/caching/helpers/helpers.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from "vitest"; + +import { memoizeAsync } from "./helpers"; + +describe(memoizeAsync.name, () => { + it("memoizes successful results", async () => { + const fn = vi.fn().mockResolvedValue("result"); + const memoized = memoizeAsync(fn); + + const result1 = await memoized(); + const result2 = await memoized(); + + expect(result1).toBe("result"); + expect(result2).toBe("result"); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it("does not cache rejected promises", async () => { + const error = new Error("Test error"); + const fn = vi.fn().mockRejectedValue(error); + const memoized = memoizeAsync(fn); + + await expect(memoized()).rejects.toThrow("Test error"); + await expect(memoized()).rejects.toThrow("Test error"); + + expect(fn).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/lib/caching/helpers/helpers.ts b/src/lib/caching/helpers/helpers.ts new file mode 100644 index 0000000..9164de4 --- /dev/null +++ b/src/lib/caching/helpers/helpers.ts @@ -0,0 +1,23 @@ +export function memoizeAsync Promise>(fn: T): T & { cache: Map> } { + const cache = new Map>(); + const memoizedFn = ((...args: Parameters) => { + const key = JSON.stringify(args); + + const cached = cache.get(key); + if (cached) { + return cached; + } + + const promise = fn(...args) as ReturnType; + + promise.catch(() => { + cache.delete(key); + }); + + cache.set(key, promise); + return promise; + }) as unknown as T & { cache: Map> }; + memoizedFn.cache = cache; + + return memoizedFn; +} diff --git a/src/lib/signing-stargate-client/signing-stargate-client.service.test.ts b/src/lib/signing-stargate-client/signing-stargate-client.service.test.ts new file mode 100644 index 0000000..1c0b869 --- /dev/null +++ b/src/lib/signing-stargate-client/signing-stargate-client.service.test.ts @@ -0,0 +1,259 @@ +import { encodeSecp256k1Pubkey } from "@cosmjs/amino"; +import { toBase64 } from "@cosmjs/encoding"; +import type { EncodeObject, OfflineDirectSigner, Registry } from "@cosmjs/proto-signing"; +import { encodePubkey } from "@cosmjs/proto-signing"; +import type { Account } from "@cosmjs/stargate"; +import { QueryClient } from "@cosmjs/stargate"; +import type { Comet38Client } from "@cosmjs/tendermint-rpc"; +import { faker } from "@faker-js/faker"; +import { SimulateRequest, SimulateResponse } from "cosmjs-types/cosmos/tx/v1beta1/service"; +import { SignMode } from "cosmjs-types/cosmos/tx/signing/v1beta1/signing"; +import { AuthInfo, TxBody, TxRaw } from "cosmjs-types/cosmos/tx/v1beta1/tx"; +import { describe, expect, it, vi } from "vitest"; +import { mock } from "vitest-mock-extended"; + +import { SigningStargateClientService, type SigningClient } from "./signing-stargate-client.service"; + +describe(SigningStargateClientService.name, () => { + it("signs an unordered transaction with a zero sequence and a future timeout", async () => { + const ttlMs = 120_000; + const { client } = setup({ ttlMs }); + const before = Date.now(); + + const txRaw = await client.signUnordered(createMessages()); + + const body = TxBody.decode(txRaw.bodyBytes); + const authInfo = AuthInfo.decode(txRaw.authInfoBytes); + + expect(body.unordered).toBe(true); + expect(timestampToMillis(body.timeoutTimestamp)).toBeGreaterThanOrEqual(before + ttlMs); + expect(authInfo.signerInfos[0].sequence).toBe(0n); + }); + + it("derives the timeout timestamp after gas simulation so estimation latency does not erode the ttl", async () => { + vi.useFakeTimers(); + try { + const ttlMs = 120_000; + const simulationLatencyMs = 5_000; + const startTime = 1_700_000_000_000; + vi.setSystemTime(startTime); + const { client } = setup({ ttlMs, onSimulate: () => vi.setSystemTime(startTime + simulationLatencyMs) }); + + const txRaw = await client.signUnordered(createMessages()); + + const body = TxBody.decode(txRaw.bodyBytes); + expect(timestampToMillis(body.timeoutTimestamp)).toBe(startTime + simulationLatencyMs + ttlMs); + } finally { + vi.useRealTimers(); + } + }); + + it("signs over the chain id and account number resolved through the injected signing client", async () => { + const { client, wallet, signingClient, address, accountNumber } = setup(); + + await client.signUnordered(createMessages()); + + expect(signingClient.getChainId).toHaveBeenCalledTimes(1); + expect(signingClient.getAccount).toHaveBeenCalledWith(address); + + const [signerAddress, signDoc] = wallet.signDirect.mock.calls[0]; + expect(signerAddress).toBe(address); + expect(signDoc.chainId).toBe("test-chain"); + expect(signDoc.accountNumber).toBe(BigInt(accountNumber)); + }); + + it("signs with the public key from the injected signer rather than the on-chain account", async () => { + // The composed signing client resolves an account with no pubkey on chain (true until it has signed once), so the + // signer info can only have come from the injected signer. + const { client, signerPubkey } = setup(); + + const txRaw = await client.signUnordered(createMessages()); + + const authInfo = AuthInfo.decode(txRaw.authInfoBytes); + expect(authInfo.signerInfos[0].publicKey).toEqual(encodePubkey(encodeSecp256k1Pubkey(signerPubkey))); + }); + + it("encodes messages through the registry of the injected signing client", async () => { + const { client, encodeAsAny } = setup(); + + await client.signUnordered(createMessages()); + + expect(encodeAsAny).toHaveBeenCalledWith(createMessages()[0]); + }); + + it("stamps the akash console memo and prices the fee from the estimated gas", async () => { + const { client } = setup({ gasUsed: 2000, gasMultiplier: 1.2, averageGasPrice: 0.025 }); + + const txRaw = await client.signUnordered(createMessages()); + + expect(TxBody.decode(txRaw.bodyBytes).memo).toBe("akash price update"); + // 2000 simulated gas x 1.2 = 2400 gasWanted, at 0.025uakt = 60uakt. + expect(AuthInfo.decode(txRaw.authInfoBytes).fee).toEqual(expect.objectContaining({ + gasLimit: 2400n, + amount: [{ denom: "uakt", amount: "60" }], + })); + }); + + it("estimates gas by simulating the unordered tx body and applies the safety multiplier", async () => { + const { client, abciQuery } = setup({ gasUsed: 2000, gasMultiplier: 1.2 }); + + const txRaw = await client.signUnordered(createMessages()); + + // The gas must be simulated for the exact unordered tx that gets broadcast so the estimate accounts for the extra + // ante-handler nonce write; otherwise the tx lands out of gas (code 11). The composed SigningClient seam + // deliberately exposes no `simulate`, which would rebuild an ordered body and under-count. + const simulatedTx = TxRaw.decode(SimulateRequest.decode(abciQuery.mock.calls[0][0].data).txBytes); + const simulatedAuthInfo = AuthInfo.decode(simulatedTx.authInfoBytes); + expect(TxBody.decode(simulatedTx.bodyBytes).unordered).toBe(true); + expect(simulatedAuthInfo.signerInfos[0].sequence).toBe(0n); + + // An empty fee and an unset sign mode keep the node treating this as a gas simulation, skipping signature checks. + expect(simulatedAuthInfo.fee).toEqual(expect.objectContaining({ gasLimit: 0n, amount: [] })); + expect(simulatedAuthInfo.signerInfos[0].modeInfo?.single?.mode).toBe(SignMode.SIGN_MODE_UNSPECIFIED); + + expect(AuthInfo.decode(txRaw.authInfoBytes).fee!.gasLimit).toBe(2400n); + }); + + it("uses the provided gas limit verbatim and skips simulation when a gas override is passed", async () => { + const { client, abciQuery, signingClient } = setup({ gasMultiplier: 1.3 }); + + const txRaw = await client.signUnordered(createMessages(), { gas: 5000 }); + + // The gas-recovery path must not re-simulate: the override comes from the actual on-chain gasUsed, which is more + // reliable than the simulate estimate that under-counted in the first place. + expect(abciQuery).not.toHaveBeenCalled(); + expect(signingClient.forceGetQueryClient).not.toHaveBeenCalled(); + expect(AuthInfo.decode(txRaw.authInfoBytes).fee!.gasLimit).toBe(5000n); + }); + + it("attaches the fee granter to the signed transaction when provided", async () => { + const { client } = setup(); + + const txRaw = await client.signUnordered(createMessages(), { granter: "akash1granter" }); + + expect(AuthInfo.decode(txRaw.authInfoBytes).fee!.granter).toBe("akash1granter"); + }); + + it("fetches chain id and account data only once across concurrent signs", async () => { + const { client, wallet, signingClient } = setup(); + + await Promise.all(Array.from({ length: 4 }, () => client.signUnordered(createMessages()))); + + expect(signingClient.getChainId).toHaveBeenCalledTimes(1); + expect(signingClient.getAccount).toHaveBeenCalledTimes(1); + expect(wallet.getAccounts).toHaveBeenCalledTimes(1); + }); + + it("takes the query client from the injected signing client once and reuses it for later simulations", async () => { + const { client, signingClient, abciQuery } = setup(); + + await client.signUnordered(createMessages()); + await client.signUnordered(createMessages()); + + expect(signingClient.forceGetQueryClient).toHaveBeenCalledTimes(1); + expect(abciQuery).toHaveBeenCalledTimes(2); + }); + + it("rejects a signer that cannot sign in direct mode", () => { + const { createClient } = setup(); + // A plain amino-only signer: no signDirect, so the direct-sign pipeline this service builds by hand cannot work. + const aminoSigner = { getAccounts: vi.fn(), signAmino: vi.fn() } as unknown as OfflineDirectSigner; + + expect(() => createClient(aminoSigner)).toThrow("requires a direct signer"); + }); + + it("throws when the injected signing client cannot resolve the account", async () => { + const { client, signingClient } = setup(); + signingClient.getAccount.mockResolvedValue(null); + + await expect(client.signUnordered(createMessages())).rejects.toThrow("Failed to get account info"); + }); + + it("looks the account up again on the next sign after a failed lookup", async () => { + const { client, wallet, signingClient, address, accountNumber } = setup(); + signingClient.getAccount.mockResolvedValueOnce(null); + + await expect(client.signUnordered(createMessages())).rejects.toThrow("Failed to get account info"); + await client.signUnordered(createMessages()); + + // A failed lookup must not be memoized, otherwise a transient RPC failure would poison the daemon for its lifetime. + expect(signingClient.getAccount).toHaveBeenNthCalledWith(2, address); + expect(wallet.signDirect.mock.calls[0][1].accountNumber).toBe(BigInt(accountNumber)); + }); + + it("throws when the simulation returns no gas info", async () => { + const { client, abciQuery } = setup(); + abciQuery.mockResolvedValue({ code: 0, value: SimulateResponse.encode(SimulateResponse.fromPartial({})).finish(), height: 1 }); + + await expect(client.signUnordered(createMessages())).rejects.toThrow("no gas info returned"); + }); + + function createMessages(): readonly EncodeObject[] { + return [{ typeUrl: "/test.MsgTest", value: {} }]; + } + + function setup(input?: { ttlMs?: number; gasUsed?: number; gasMultiplier?: number; averageGasPrice?: number; onSimulate?: () => void }) { + const address = "akash1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5lzv7xu"; + const accountNumber = faker.number.int({ min: 1, max: 1000 }); + const gasUsed = input?.gasUsed ?? 2000; + + // A valid 33-byte compressed secp256k1 public key so the real encodeSecp256k1Pubkey pipeline accepts it. + const signerPubkey = new Uint8Array(33); + signerPubkey[0] = 0x02; + + const wallet = mock({ + getAccounts: vi.fn().mockResolvedValue([{ address, algo: "secp256k1", pubkey: signerPubkey }]), + }); + wallet.signDirect.mockImplementation(async (_address, signDoc) => ({ + signature: { pub_key: { type: "", value: "" }, signature: toBase64(new Uint8Array(64)) }, + signed: { + bodyBytes: signDoc.bodyBytes, + authInfoBytes: signDoc.authInfoBytes, + chainId: signDoc.chainId, + accountNumber: signDoc.accountNumber, + }, + })); + + const encodeAsAny = vi.fn((message: EncodeObject) => ({ + typeUrl: message.typeUrl, + value: new TextEncoder().encode(JSON.stringify(message.value)), + })); + const registry = { encodeAsAny } as unknown as Registry; + + // Gas estimation drives the real query pipeline through the injected client, so mock the underlying ABCI query. + const abciQuery = vi.fn().mockImplementation(async () => { + input?.onSimulate?.(); + return { + code: 0, + value: SimulateResponse.encode(SimulateResponse.fromPartial({ gasInfo: { gasUsed: BigInt(gasUsed), gasWanted: BigInt(gasUsed) } })).finish(), + height: 1, + }; + }); + const cometClient = mock({ abciQuery }); + const queryClient = new QueryClient(cometClient); + + // The service composes this seam instead of extending SigningStargateClient: the registry, chain data, account data + // and query client all come from here, so an in-memory stand-in is enough to drive the whole signing flow. + const account: Account = { address, accountNumber, sequence: 0, pubkey: null }; + const signingClient = mock({ + registry, + getChainId: vi.fn().mockResolvedValue("test-chain"), + getAccount: vi.fn().mockResolvedValue(account), + forceGetQueryClient: vi.fn(() => queryClient), + }); + + const createClient = (signer: OfflineDirectSigner) => new SigningStargateClientService(signingClient, signer, { + ttlMs: input?.ttlMs ?? 180_000, + gasMultiplier: input?.gasMultiplier ?? 1.2, + averageGasPrice: input?.averageGasPrice ?? 0.025, + }); + + return { client: createClient(wallet), createClient, wallet, signerPubkey, signingClient, encodeAsAny, cometClient, abciQuery, account, address, accountNumber }; + } + + function timestampToMillis(timestamp: TxBody["timeoutTimestamp"]): number { + expect(timestamp).toBeDefined(); + + return Number(timestamp!.seconds) * 1_000 + timestamp!.nanos / 1_000_000; + } +}); diff --git a/src/lib/signing-stargate-client/signing-stargate-client.service.ts b/src/lib/signing-stargate-client/signing-stargate-client.service.ts new file mode 100644 index 0000000..3e0d2f2 --- /dev/null +++ b/src/lib/signing-stargate-client/signing-stargate-client.service.ts @@ -0,0 +1,158 @@ +import { encodeSecp256k1Pubkey } from "@cosmjs/amino"; +import { fromBase64 } from "@cosmjs/encoding"; +import type { EncodeObject, OfflineDirectSigner } from "@cosmjs/proto-signing"; +import { encodePubkey, isOfflineDirectSigner, makeAuthInfoBytes, makeSignDoc } from "@cosmjs/proto-signing"; +import type { Account, DeliverTxResponse, QueryClient } from "@cosmjs/stargate"; +import { calculateFee, createProtobufRpcClient, GasPrice, SigningStargateClient } from "@cosmjs/stargate"; +import { SignMode } from "cosmjs-types/cosmos/tx/signing/v1beta1/signing"; +import { ServiceClientImpl, SimulateRequest } from "cosmjs-types/cosmos/tx/v1beta1/service"; +import { TxBody, TxRaw } from "cosmjs-types/cosmos/tx/v1beta1/tx"; + +import { memoizeAsync } from "../caching/helpers/helpers.ts"; + +const MEMO = "akash price update"; +const FEES_DENOM = "uakt"; + +export interface UnorderedTxSignConfig { + /** Added to `Date.now()` to derive the tx `timeoutTimestamp` (the unordered-tx TTL). */ + ttlMs: number; + /** Multiplier applied to the simulated gas to derive `gasWanted`. */ + gasMultiplier: number; + /** Average gas price in {@link FEES_DENOM}, e.g. `0.025`. */ + averageGasPrice: number; +} + +export class SigningStargateClientService { + #queryClientService?: ServiceClientImpl; + + readonly #signer: OfflineDirectSigner; + + readonly #signConfig: UnorderedTxSignConfig; + + readonly #getChainId = memoizeAsync(() => this.#signingClient.getChainId()); + + readonly #getFirstAccount = memoizeAsync(async () => (await this.#signer.getAccounts())[0]); + + readonly #getAddress = memoizeAsync(async () => (await this.#getFirstAccount()).address); + + readonly #getPubkey = memoizeAsync(async () => encodePubkey(encodeSecp256k1Pubkey((await this.#getFirstAccount()).pubkey))); + + readonly #getAccountNumber = memoizeAsync(async () => { + const account = await this.#signingClient.getAccount(await this.#getAddress()); + + if (!account) { + throw new Error("Failed to get account info"); + } + + return account.accountNumber; + }); + + readonly #signingClient: SigningClient; + + constructor(signingClient: SigningClient, signer: OfflineDirectSigner, signConfig: UnorderedTxSignConfig) { + this.#signingClient = signingClient; + + if (!isOfflineDirectSigner(signer)) { + throw new Error("SigningStargateClientService requires a direct signer"); + } + + this.#signer = signer; + this.#signConfig = signConfig; + } + + /** + * Signs Akash transactions as {@link https://docs.cosmos.network/sdk/latest/reference/architecture/adr-070-unordered-account | unordered} + * cosmos-sdk transactions: every tx sets `unordered: true`, a `timeoutTimestamp` TTL, and a zero sequence. The chain deduplicates by + * tx hash within the TTL window instead of by account sequence, so transactions no longer need to be serialized or numbered — they can + * be signed and broadcast fully concurrently, which removes the account-sequence-mismatch failure mode entirely. + * + * Gas is estimated with {@link #simulateRawTx} rather than the inherited `simulate(signerAddress, messages, memo)`. cosmjs's `simulate` + * rebuilds its own tx body from just the messages and memo — it never sets `unordered`/`timeoutTimestamp` — so it undercounts gas: an + * unordered tx makes the ante handler write an entry to the unordered-nonce store to dedupe replays, and that extra store write is not + * exercised when simulating an ordered body. The real tx then consumes more gas than estimated and lands with `code: 11` ("out of gas + * in location: WriteFlat"). Simulating the exact body we broadcast makes the estimate account for that write. + * + * When `options.gas` is provided the simulation step is skipped and the value is used verbatim as the gas limit. This is the + * gas-recovery path: some messages (e.g. an escrow deposit that settles accrued rent) consume gas that grows with the block height + * they land in, so simulation structurally under-counts them. After such a tx lands out of gas the caller re-signs with a gas limit + * derived from the actual on-chain `gasUsed` — a far more reliable figure than re-simulating, which just repeats the under-count. + */ + async signUnordered(messages: readonly EncodeObject[], options?: { granter?: string; gas?: number }): Promise { + const [address, chainId, accountNumber, pubkey] = await Promise.all([this.#getAddress(), this.#getChainId(), this.#getAccountNumber(), this.#getPubkey()]); + + const txBody = this.#buildTxBody(messages); + const fee = await this.#estimateFee(TxBody.encode(txBody).finish(), pubkey, { granter: options?.granter, gas: options?.gas }); + + // sequence MUST be 0 for unordered transactions; the chain rejects a non-zero sequence when unordered is set. + const authInfoBytes = makeAuthInfoBytes([{ pubkey, sequence: 0 }], fee.amount, Number(fee.gas), fee.granter, fee.payer); + // update timestampTimeout to compensate time spent in doing stuff above + txBody.timeoutTimestamp = toTimestamp(Date.now() + this.#signConfig.ttlMs); + const bodyBytes = TxBody.encode(txBody).finish(); + const signDoc = makeSignDoc(bodyBytes, authInfoBytes, chainId, accountNumber); + const { signature, signed } = await this.#signer.signDirect(address, signDoc); + + return TxRaw.fromPartial({ + bodyBytes: signed.bodyBytes, + authInfoBytes: signed.authInfoBytes, + signatures: [fromBase64(signature.signature)], + }); + } + + async #simulateRawTx(txBytes: Uint8Array): Promise { + this.#queryClientService ??= new ServiceClientImpl(createProtobufRpcClient(this.#signingClient.forceGetQueryClient())); + const { gasInfo } = await this.#queryClientService.Simulate(SimulateRequest.fromPartial({ txBytes })); + + if (!gasInfo) { + throw new Error("Failed to simulate transaction: no gas info returned"); + } + + return Number(gasInfo.gasUsed); + } + + #buildTxBody(messages: readonly EncodeObject[]) { + return TxBody.fromPartial({ + messages: messages.map(message => this.#signingClient.registry.encodeAsAny(message)), + memo: MEMO, + unordered: true, + timeoutTimestamp: toTimestamp(Date.now() + this.#signConfig.ttlMs), + }); + } + + async #estimateFee(bodyBytes: Uint8Array, pubkey: ReturnType, options: { granter?: string; gas?: number }) { + const gas = options.gas ?? (await this.#estimateGas(bodyBytes, pubkey)); + const fee = calculateFee(gas, GasPrice.fromString(`${this.#signConfig.averageGasPrice}${FEES_DENOM}`)); + + return options.granter ? { ...fee, granter: options.granter } : fee; + } + + async #estimateGas(bodyBytes: Uint8Array, pubkey: ReturnType): Promise { + // Empty fee with an unset sign mode: this keeps the node treating the tx as a gas simulation rather than one to + // execute (it skips signature verification when simulating). Sequence stays 0 to match the unordered tx we sign. + const authInfoBytes = makeAuthInfoBytes([{ pubkey, sequence: 0 }], [], 0, undefined, undefined, SignMode.SIGN_MODE_UNSPECIFIED); + const txBytes = TxRaw.encode(TxRaw.fromPartial({ bodyBytes, authInfoBytes, signatures: [new Uint8Array()] })).finish(); + + const gasEstimation = await this.#simulateRawTx(txBytes); + return Math.ceil(gasEstimation * this.#signConfig.gasMultiplier); + } + + async broadcastTx(tx: Uint8Array, timeoutMs?: number, pollIntervalMs?: number): Promise { + const result = await this.#signingClient.broadcastTx(tx, timeoutMs, pollIntervalMs); + return result; + } +} + +function toTimestamp(millis: number) { + const seconds = Math.floor(millis / 1_000); + const nanos = (millis - seconds * 1_000) * 1_000_000; + return { seconds: BigInt(seconds), nanos }; +} + +export interface SigningClient { + registry: SigningStargateClient["registry"]; + getChainId(): Promise; + getAccount(searchAddress: string): Promise; + broadcastTx(tx: Uint8Array, timeoutMs?: number, pollIntervalMs?: number): Promise; + broadcastTxSync(tx: Uint8Array): Promise; + forceGetQueryClient(): QueryClient; + disconnect(): void; +} diff --git a/src/price-update/price-update-ordered/price-update-ordered.test.ts b/src/price-update/price-update-ordered/price-update-ordered.test.ts deleted file mode 100644 index 9ee2204..0000000 --- a/src/price-update/price-update-ordered/price-update-ordered.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { mock } from "vitest-mock-extended"; -import type { SigningCosmWasmClient } from "@cosmjs/cosmwasm-stargate"; -import { GasPrice } from "@cosmjs/stargate"; -import { PriceUpdateOrdered } from "./price-update-ordered"; -import type { PriceUpdate, PriceUpdateOptions } from "../../types"; - -describe(PriceUpdateOrdered.name, () => { - it("executes contract with correct message and funds", async () => { - const { signingClient, updater } = setup(); - signingClient.execute.mockResolvedValue({ - transactionHash: "tx123", - gasUsed: 200_000n, - gasWanted: 300_000n, - height: 1, - logs: [], - events: [], - }); - - await updater.updatePrice(priceUpdate, options); - - expect(signingClient.execute).toHaveBeenCalledWith( - "akash1sender", - "akash1contract", - { update_price_feed: { vaa: "base64-encoded-vaa" } }, - "auto", - undefined, - [{ denom: "uakt", amount: "1000" }], - ); - }); - - it("returns transactionHash and gasUsed from result", async () => { - const { signingClient, updater } = setup(); - signingClient.execute.mockResolvedValue({ - transactionHash: "abc", - gasUsed: 150_000n, - gasWanted: 200_000n, - height: 1, - logs: [], - events: [], - }); - - const result = await updater.updatePrice(priceUpdate, options); - - expect(result).toEqual({ - transactionHash: "abc", - gasUsed: 150_000n, - }); - }); - - it("propagates execution errors", async () => { - const { signingClient, updater } = setup(); - signingClient.execute.mockRejectedValue(new Error("out of gas")); - - await expect(updater.updatePrice(priceUpdate, options)).rejects.toThrow("out of gas"); - }); - - const options: PriceUpdateOptions = { - senderAddress: "akash1sender", - contractAddress: "akash1contract", - denom: "uakt", - updateFee: "1000", - gasPrice: GasPrice.fromString("0.025uakt"), - }; - - const priceUpdate: PriceUpdate = { - priceData: { - id: "price-feed-id", - price: { price: "100", conf: "1", expo: -8, publish_time: 1000 }, - ema_price: { price: "99", conf: "2", expo: -8, publish_time: 1000 }, - }, - vaa: "base64-encoded-vaa", - }; - - function setup() { - const signingClient = mock(); - const updater = new PriceUpdateOrdered(signingClient); - return { signingClient, updater }; - } - -}); diff --git a/src/price-update/price-update-ordered/price-update-ordered.ts b/src/price-update/price-update-ordered/price-update-ordered.ts deleted file mode 100644 index 0bc43de..0000000 --- a/src/price-update/price-update-ordered/price-update-ordered.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { SigningCosmWasmClient } from "@cosmjs/cosmwasm-stargate"; -import type { PriceUpdate, PriceUpdateOptions, PriceUpdater, UpdatePriceFeedMsg } from "../../types.ts"; - -export class PriceUpdateOrdered implements PriceUpdater { - readonly #signingClient: SigningCosmWasmClient; - - constructor(signingClient: SigningCosmWasmClient) { - this.#signingClient = signingClient; - } - - async updatePrice(priceUpdate: PriceUpdate, options: PriceUpdateOptions): Promise<{ transactionHash: string; gasUsed: bigint }> { - // 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 - const msg: UpdatePriceFeedMsg = { - update_price_feed: { - vaa: priceUpdate.vaa, - }, - }; - const result = await this.#signingClient.execute( - options.senderAddress, - options.contractAddress, - msg, - "auto", - undefined, - [{ denom: options.denom, amount: options.updateFee }], - ); - return { - transactionHash: result.transactionHash, - gasUsed: result.gasUsed, - }; - } -} diff --git a/src/price-update/price-update-unordered/price-update-unordered.test.ts b/src/price-update/price-update-unordered/price-update-unordered.test.ts deleted file mode 100644 index fa34c24..0000000 --- a/src/price-update/price-update-unordered/price-update-unordered.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { mock, mockDeep } from "vitest-mock-extended"; -import type { SigningCosmWasmClient } from "@cosmjs/cosmwasm-stargate"; -import type { OfflineDirectSigner } from "@cosmjs/proto-signing"; -import type { Account, DeliverTxResponse } from "@cosmjs/stargate"; -import { GasPrice } from "@cosmjs/stargate"; -import { fromBase64, toBase64, toUtf8 } from "@cosmjs/encoding"; -import { TxRaw } from "cosmjs-types/cosmos/tx/v1beta1/tx"; -import { PriceUpdateUnordered } from "./price-update-unordered"; -import type { PriceUpdate, PriceUpdateOptions } from "../../types"; - -describe(PriceUpdateUnordered.name, () => { - it("returns the transaction hash from the broadcast", async () => { - const { updater, signingClient } = setup(); - signingClient.broadcastTx.mockResolvedValue({ ...deliveredTx, transactionHash: "broadcast-hash" }); - - const result = await updater.updatePrice(priceUpdate, options); - - // gasUsed is the buffered fee estimate, not the delivered tx's gasUsed. - expect(result).toEqual({ transactionHash: "broadcast-hash", gasUsed: BigInt(100_000 * 1.5) }); - }); - - it("executes the price feed contract with the VAA and update fee", async () => { - const { updater, signingClient } = setup(); - - await updater.updatePrice(priceUpdate, options); - - expect(signingClient.registry.encodeAsAny).toHaveBeenCalledWith({ - typeUrl: "/cosmwasm.wasm.v1.MsgExecuteContract", - value: expect.objectContaining({ - sender: "akash1sender", - contract: "akash1contract", - msg: toUtf8(JSON.stringify({ update_price_feed: { vaa: "base64-encoded-vaa" } })), - funds: [{ denom: "uakt", amount: "1000" }], - }), - }); - }); - - it("estimates the fee from a simulation with a 1.5x gas buffer", async () => { - const { updater, signingClient } = setup(); - signingClient.simulate.mockResolvedValue(100_000); - - await updater.updatePrice(priceUpdate, options); - - expect(signingClient.simulate).toHaveBeenCalledWith("akash1sender", expect.any(Array), ""); - }); - - it("signs with the sender address and broadcasts the signed bytes", async () => { - const { updater, signingClient, signer } = setup(); - - await updater.updatePrice(priceUpdate, options); - - expect(signer.signDirect).toHaveBeenCalledWith("akash1sender", expect.anything()); - - const expectedTx = TxRaw.encode(TxRaw.fromPartial({ - bodyBytes: signedBodyBytes, - authInfoBytes: signedAuthInfoBytes, - signatures: [fromBase64(signatureBase64)], - })).finish(); - expect(signingClient.broadcastTx).toHaveBeenCalledWith(expectedTx); - }); - - it("caches the chain id and account across calls", async () => { - const { updater, signingClient } = setup(); - - await updater.updatePrice(priceUpdate, options); - await updater.updatePrice(priceUpdate, options); - - expect(signingClient.getChainId).toHaveBeenCalledTimes(1); - expect(signingClient.getAccount).toHaveBeenCalledTimes(1); - }); - - it("throws when the account is not found on chain", async () => { - const { updater, signingClient } = setup(); - signingClient.getAccount.mockResolvedValue(null); - - await expect(updater.updatePrice(priceUpdate, options)).rejects.toThrow( - "Account akash1sender not found on chain", - ); - }); - - it("throws when the account has no public key on chain", async () => { - const { updater, signingClient } = setup(); - signingClient.getAccount.mockResolvedValue({ ...account, pubkey: null }); - - await expect(updater.updatePrice(priceUpdate, options)).rejects.toThrow( - "has no public key on chain", - ); - }); - - it("propagates broadcast errors", async () => { - const { updater, signingClient } = setup(); - signingClient.broadcastTx.mockRejectedValue(new Error("mempool full")); - - await expect(updater.updatePrice(priceUpdate, options)).rejects.toThrow("mempool full"); - }); - - const options: PriceUpdateOptions = { - senderAddress: "akash1sender", - contractAddress: "akash1contract", - denom: "uakt", - updateFee: "1000", - gasPrice: GasPrice.fromString("0.025uakt"), - }; - - const priceUpdate: PriceUpdate = { - priceData: { - id: "price-feed-id", - price: { price: "100", conf: "1", expo: -8, publish_time: 1000 }, - ema_price: { price: "99", conf: "2", expo: -8, publish_time: 1000 }, - }, - vaa: "base64-encoded-vaa", - }; - - // A valid compressed secp256k1 public key (33 bytes starting with 0x02) so the - // real encodePubkey/makeAuthInfoBytes pipeline accepts it. - const compressedPubkey = new Uint8Array(33); - compressedPubkey[0] = 0x02; - - const account: Account = { - address: "akash1sender", - pubkey: { type: "tendermint/PubKeySecp256k1", value: toBase64(compressedPubkey) }, - accountNumber: 7, - sequence: 3, - }; - - // gasUsed is deliberately different from the buffered fee estimate (150_000) so the - // assertions show which of the two updatePrice reports. - const deliveredTx: DeliverTxResponse = { - height: 42, - txIndex: 0, - code: 0, - transactionHash: "tx-hash", - events: [], - msgResponses: [], - gasUsed: 90_000n, - gasWanted: 150_000n, - }; - - const signedBodyBytes = new Uint8Array([1, 2, 3]); - const signedAuthInfoBytes = new Uint8Array([4, 5, 6]); - const signatureBase64 = toBase64(new Uint8Array(64)); - - function setup() { - const signingClient = mockDeep(); - const signer = mock(); - - signingClient.getChainId.mockResolvedValue("akash-testnet"); - signingClient.getAccount.mockResolvedValue(account); - signingClient.simulate.mockResolvedValue(100_000); - signingClient.registry.encodeAsAny.mockReturnValue({ typeUrl: "/cosmwasm.wasm.v1.MsgExecuteContract", value: new Uint8Array([9]) }); - signingClient.broadcastTx.mockResolvedValue(deliveredTx); - signer.signDirect.mockResolvedValue({ - signature: { pub_key: { type: "", value: "" }, signature: signatureBase64 }, - signed: { - bodyBytes: signedBodyBytes, - authInfoBytes: signedAuthInfoBytes, - chainId: "akash-testnet", - accountNumber: 7n, - }, - }); - - const updater = new PriceUpdateUnordered(signingClient, signer); - return { signingClient, signer, updater }; - } -}); diff --git a/src/price-update/price-update-unordered/price-update-unordered.ts b/src/price-update/price-update-unordered/price-update-unordered.ts deleted file mode 100644 index daf537c..0000000 --- a/src/price-update/price-update-unordered/price-update-unordered.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { SigningCosmWasmClient } from "@cosmjs/cosmwasm-stargate"; -import { fromBase64, toUtf8 } from "@cosmjs/encoding"; -import { type EncodeObject, encodePubkey, makeAuthInfoBytes, makeSignDoc, type OfflineDirectSigner } from "@cosmjs/proto-signing"; -import { type Account, calculateFee, type GasPrice, type StdFee } from "@cosmjs/stargate"; -import { TxBody, TxRaw } from "cosmjs-types/cosmos/tx/v1beta1/tx"; -import { MsgExecuteContract } from "cosmjs-types/cosmwasm/wasm/v1/tx"; -import { Timestamp } from "cosmjs-types/google/protobuf/timestamp"; -import type { PriceUpdate, PriceUpdateOptions, PriceUpdater, UpdatePriceFeedMsg } from "../../types.ts"; - -export class PriceUpdateUnordered implements PriceUpdater { - readonly #signingClient: SigningCosmWasmClient; - readonly #signer: OfflineDirectSigner; - #chainId?: string; - #account?: Account | null; - - constructor(signingClient: SigningCosmWasmClient, signer: OfflineDirectSigner) { - this.#signingClient = signingClient; - this.#signer = signer; - } - - async updatePrice(priceUpdate: PriceUpdate, options: PriceUpdateOptions): Promise<{ transactionHash: string; gasUsed?: bigint }> { - this.#chainId ??= await this.#signingClient.getChainId(); - this.#account ??= await this.#signingClient.getAccount(options.senderAddress); - - if (!this.#account) { - throw new Error(`Account ${options.senderAddress} not found on chain`); - } - - // 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 - const msg: UpdatePriceFeedMsg = { - update_price_feed: { - vaa: priceUpdate.vaa, - }, - }; - const messages: EncodeObject[] = [ - { - typeUrl: "/cosmwasm.wasm.v1.MsgExecuteContract", - value: MsgExecuteContract.fromPartial({ - sender: options.senderAddress, - contract: options.contractAddress, - msg: toUtf8(JSON.stringify(msg)), - funds: [{ denom: options.denom, amount: options.updateFee }], - }), - }, - ]; - const fee = await this.#calcFee(messages, options.gasPrice); - const tx = await this.#signUnordered( - this.#account, - messages, - fee, - ); - const deliveredTx = await this.#signingClient.broadcastTx(TxRaw.encode(tx).finish()); - if (deliveredTx.code !== 0) { - throw new Error(`Broadcast failed with code ${deliveredTx.code}`); - } - return { - transactionHash: deliveredTx.transactionHash, - gasUsed: BigInt(fee.gas), - }; - } - - async #signUnordered( - account: Account, - messages: EncodeObject[], - fee: StdFee, - memo?: string | undefined, - ) { - if (!account.pubkey) { - throw new Error(`Account ${account.address} has no public key on chain (it must sign at least one tx first)`); - } - const pubkey = encodePubkey(account.pubkey); - const ttlMs = 3 * 60_000; - const futureMs = Date.now() + ttlMs; - const txBodyBytes = TxBody.encode(TxBody.fromPartial({ - messages: messages.map((msg) => this.#signingClient.registry.encodeAsAny(msg)), - memo: memo ?? "", - unordered: true, - timeoutTimestamp: Timestamp.fromPartial({ - seconds: BigInt(Math.floor(futureMs / 1000)), - nanos: (futureMs % 1000) * 1_000_000, - }), - })).finish(); - - const authInfoBytes = makeAuthInfoBytes( - [{ pubkey, sequence: 0 }], - fee.amount, Number(fee.gas), fee.granter, fee.payer, - ); - - const signDoc = makeSignDoc(txBodyBytes, authInfoBytes, this.#chainId!, account.accountNumber); - const { signature, signed } = await this.#signer.signDirect(account.address, signDoc); - - return TxRaw.fromPartial({ - bodyBytes: signed.bodyBytes, - authInfoBytes: signed.authInfoBytes, - signatures: [fromBase64(signature.signature)], - }); - } - - async #calcFee(messages: EncodeObject[], gasPrice: GasPrice, memo?: string): Promise { - const gasEstimation = await this.#signingClient.simulate( - this.#account!.address, - messages, - memo ?? "", - ); - - return calculateFee(Math.ceil(gasEstimation * 1.5), gasPrice); - } -} diff --git a/src/services/contract-client/contract-client.service.test.ts b/src/services/contract-client/contract-client.service.test.ts new file mode 100644 index 0000000..9a6e5f4 --- /dev/null +++ b/src/services/contract-client/contract-client.service.test.ts @@ -0,0 +1,380 @@ +import type { ExecuteResult, SigningCosmWasmClient } from "@cosmjs/cosmwasm-stargate"; +import { fromUtf8 } from "@cosmjs/encoding"; +import { Registry, type EncodeObject } from "@cosmjs/proto-signing"; +import { defaultRegistryTypes, GasPrice, QueryClient, type Account, type DeliverTxResponse } from "@cosmjs/stargate"; +import type { Comet38Client } from "@cosmjs/tendermint-rpc"; +import { SimulateResponse } from "cosmjs-types/cosmos/tx/v1beta1/service"; +import { TxBody, TxRaw } from "cosmjs-types/cosmos/tx/v1beta1/tx"; +import { MsgExecuteContract } from "cosmjs-types/cosmwasm/wasm/v1/tx"; +import { describe, expect, it, vi } from "vitest"; +import { mock } from "vitest-mock-extended"; + +import type { SigningClient } from "../../lib/signing-stargate-client/signing-stargate-client.service.ts"; +import type { PriceUpdate } from "../../types.ts"; +import { BroadcastError, ContractClientService, type ConfigResponse, type SigningClientServiceConfig } from "./contract-client.service.ts"; + +/** Well-known test vectors: the derived addresses pin both the akash prefix and the cosmos HD path. */ +const PRIVATE_KEY = "b1c2f3d4e5a60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90"; +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 RPC_ENDPOINT = "https://rpc.akashnet.net:443"; + +describe(ContractClientService.name, () => { + describe("getAccount", () => { + it("derives the account from a hex private key", async () => { + const { client } = setup({ walletSecret: { type: "privateKey", value: PRIVATE_KEY } }); + + const account = await client.getAccount(); + + expect(account.address).toBe(PRIVATE_KEY_ADDRESS); + expect(account.algo).toBe("secp256k1"); + }); + + it("derives the account from a mnemonic", async () => { + const { client } = setup({ walletSecret: { type: "mnemonic", value: MNEMONIC } }); + + const account = await client.getAccount(); + + expect(account.address).toBe(MNEMONIC_ADDRESS); + }); + }); + + describe("rpc connection", () => { + it("connects to the configured endpoint with the derived signer and gas price", async () => { + const { client, connectWithSigner } = setup({ rpcEndpoint: RPC_ENDPOINT, gasPrice: "0.03uakt" }); + + await client.queryCurrentPrice(); + + const [endpoint, signer, options] = connectWithSigner.mock.calls[0]; + expect(endpoint).toBe(RPC_ENDPOINT); + expect(await signer.getAccounts()).toEqual([expect.objectContaining({ address: PRIVATE_KEY_ADDRESS })]); + expect(options?.gasPrice).toEqual(GasPrice.fromString("0.03uakt")); + }); + + it("opens a single connection shared by every operation", async () => { + const { client, connectWithSigner, signingClient } = setup(); + signingClient.queryContractSmart.mockResolvedValue(createConfigResponse()); + + await Promise.all([client.queryConfig(), client.queryCurrentPrice(), client.getAccount()]); + await client.queryPriceFeed(); + + expect(connectWithSigner).toHaveBeenCalledTimes(1); + }); + + it("retries the connection on the next call after a failed one", async () => { + const { client, connectWithSigner } = setup(); + connectWithSigner.mockRejectedValueOnce(new Error("rpc unreachable")); + + // A memoized rejection would poison the daemon for its whole lifetime after one transient RPC failure. + await expect(client.queryCurrentPrice()).rejects.toThrow("rpc unreachable"); + await expect(client.queryCurrentPrice()).resolves.toBeDefined(); + expect(connectWithSigner).toHaveBeenCalledTimes(2); + }); + }); + + describe("updatePrice in an ordered tx", () => { + it("executes the price feed update against the contract, funding it with the update fee", async () => { + const { client, signingClient, priceUpdate } = setup({ priceUpdateTxMethod: "ordered", denom: "uakt", gasMultiplier: 1.5 }); + + const result = await client.updatePrice(priceUpdate, { updateFee: "250" }); + + expect(signingClient.execute).toHaveBeenCalledWith( + PRIVATE_KEY_ADDRESS, + CONTRACT_ADDRESS, + { update_price_feed: { vaa: priceUpdate.vaa } }, + 1.5, + undefined, + [{ denom: "uakt", amount: "250" }], + ); + expect(result).toEqual({ transactionHash: "ORDERED_TX_HASH", gasUsed: 120_000n }); + }); + + it("is the default when no tx method is configured", async () => { + const { client, signingClient, priceUpdate } = setup({ priceUpdateTxMethod: undefined }); + + await client.updatePrice(priceUpdate, { updateFee: "1" }); + + expect(signingClient.execute).toHaveBeenCalledTimes(1); + expect(signingClient.broadcastTx).not.toHaveBeenCalled(); + }); + }); + + describe("updatePrice in an unordered tx", () => { + it("broadcasts a signed unordered tx carrying the price feed update", async () => { + const { client, signingClient, priceUpdate } = setup({ priceUpdateTxMethod: "unordered", denom: "uakt" }); + + await client.updatePrice(priceUpdate, { updateFee: "250" }); + + 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(CONTRACT_ADDRESS); + expect(executeMsg.funds).toEqual([{ denom: "uakt", amount: "250" }]); + expect(JSON.parse(fromUtf8(executeMsg.msg))).toEqual({ update_price_feed: { vaa: priceUpdate.vaa } }); + }); + + it("returns the hash and gas used reported by the broadcast", async () => { + const { client, priceUpdate } = setup({ priceUpdateTxMethod: "unordered" }); + + const result = await client.updatePrice(priceUpdate, { updateFee: "250" }); + + expect(result).toEqual({ transactionHash: "UNORDERED_TX_HASH", gasUsed: 90_000n }); + }); + + it("throws when the chain rejects the broadcast", async () => { + const { client, signingClient, priceUpdate } = setup({ priceUpdateTxMethod: "unordered" }); + signingClient.broadcastTx.mockResolvedValue(mock({ code: 11, rawLog: "out of gas", transactionHash: "FAILED_TX_HASH", gasUsed: 90_000n })); + + // A non-zero code is a failed tx that still lands on chain, so the caller must not treat the hash as a success. + await expect(client.updatePrice(priceUpdate, { updateFee: "250" })).rejects.toThrow(BroadcastError); + await expect(client.updatePrice(priceUpdate, { updateFee: "250" })).rejects.toThrow("Broadcast failed with code 11"); + }); + + it("reports the chain's raw log and the tx hash of a failed broadcast", async () => { + const { client, signingClient, priceUpdate } = setup({ priceUpdateTxMethod: "unordered" }); + const rawLog = "failed to execute message; message index: 0: spendable balance 100uakt is smaller than 250uakt: insufficient funds"; + signingClient.broadcastTx.mockResolvedValue(mock({ code: 5, rawLog, transactionHash: "FAILED_TX_HASH", gasUsed: 90_000n })); + + // The code alone is ambiguous across codespaces, and the codespace never reaches us: the raw log is what names the + // failure, and the hash is what lets it be looked up on chain. Both have to survive into the message the daemon logs. + const error = await client.updatePrice(priceUpdate, { updateFee: "250" }).catch((error: unknown) => error as BroadcastError); + expect(error.message).toContain(rawLog); + expect(error.message).toContain("FAILED_TX_HASH"); + expect(error).toMatchObject({ code: 5, rawLog, transactionHash: "FAILED_TX_HASH" }); + }); + + it("points at the tx hash when the chain reports no log", async () => { + const { client, signingClient, priceUpdate } = setup({ priceUpdateTxMethod: "unordered" }); + // `rawLog` is deprecated in the sdk, so an empty one must still leave a way to diagnose the failure. + signingClient.broadcastTx.mockResolvedValue(mock({ code: 5, rawLog: "", transactionHash: "FAILED_TX_HASH", gasUsed: 90_000n })); + + await expect(client.updatePrice(priceUpdate, { updateFee: "250" })).rejects.toThrow( + "Broadcast failed with code 5 (tx FAILED_TX_HASH): chain returned no log; query the tx by hash for the module error", + ); + }); + + it("signs the price update through a single unordered signing client", async () => { + const { client, signingClient, priceUpdate } = setup({ priceUpdateTxMethod: "unordered" }); + + await client.updatePrice(priceUpdate, { updateFee: "250" }); + await client.updatePrice(priceUpdate, { updateFee: "250" }); + + expect(signingClient.broadcastTx).toHaveBeenCalledTimes(2); + expect(signingClient.getChainId).toHaveBeenCalledTimes(1); + }); + }); + + describe("contract queries", () => { + it("queries the current price", async () => { + const { client, signingClient } = setup(); + const price = { price: "12345", conf: "10", expo: -8, publish_time: 1_700_000_000 }; + signingClient.queryContractSmart.mockResolvedValue(price); + + await expect(client.queryCurrentPrice()).resolves.toEqual(price); + expect(signingClient.queryContractSmart).toHaveBeenCalledWith(CONTRACT_ADDRESS, { get_price: {} }); + }); + + it("queries the price feed", async () => { + const { client, signingClient } = setup(); + const feed = { symbol: "AKT/USD", price: "12345", conf: "10", expo: -8, publish_time: 1_700_000_000, prev_publish_time: 1_699_999_000 }; + signingClient.queryContractSmart.mockResolvedValue(feed); + + await expect(client.queryPriceFeed()).resolves.toEqual(feed); + expect(signingClient.queryContractSmart).toHaveBeenCalledWith(CONTRACT_ADDRESS, { get_price_feed: {} }); + }); + + it("queries the oracle params", async () => { + const { client, signingClient } = setup(); + const params = { max_price_deviation_bps: 500, min_price_sources: 1, max_price_staleness_blocks: 100, twap_window: 60, last_updated_height: 42 }; + signingClient.queryContractSmart.mockResolvedValue(params); + + await expect(client.queryOracleParams()).resolves.toEqual(params); + expect(signingClient.queryContractSmart).toHaveBeenCalledWith(CONTRACT_ADDRESS, { get_oracle_params: {} }); + }); + }); + + describe("queryConfig", () => { + it("queries the contract config", async () => { + const { client, signingClient } = setup(); + const config = createConfigResponse(); + signingClient.queryContractSmart.mockResolvedValue(config); + + await expect(client.queryConfig()).resolves.toEqual(config); + expect(signingClient.queryContractSmart).toHaveBeenCalledWith(CONTRACT_ADDRESS, { get_config: {} }); + }); + + it("serves the cached config until the ttl expires, then refetches", async () => { + vi.useFakeTimers(); + try { + const { client, signingClient } = setup({ smartContractConfigCacheTTLMs: 60_000 }); + signingClient.queryContractSmart.mockResolvedValue(createConfigResponse()); + const start = Date.now(); + + await client.queryConfig(); + vi.setSystemTime(start + 59_000); + await client.queryConfig(); + expect(signingClient.queryContractSmart).toHaveBeenCalledTimes(1); + + vi.setSystemTime(start + 61_000); + await client.queryConfig(); + expect(signingClient.queryContractSmart).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + it("shares one in-flight request between concurrent callers", async () => { + const { client, signingClient } = setup(); + const config = createConfigResponse(); + signingClient.queryContractSmart.mockResolvedValue(config); + + await expect(Promise.all([client.queryConfig(), client.queryConfig()])).resolves.toEqual([config, config]); + expect(signingClient.queryContractSmart).toHaveBeenCalledTimes(1); + }); + + it("does not cache a failed query", async () => { + const { client, signingClient } = setup({ smartContractConfigCacheTTLMs: 60_000 }); + const config = createConfigResponse(); + signingClient.queryContractSmart.mockRejectedValueOnce(new Error("node down")).mockResolvedValue(config); + + await expect(client.queryConfig()).rejects.toThrow("node down"); + // Still inside the ttl window: a cached rejection would keep the client broken until the ttl elapsed. + await expect(client.queryConfig()).resolves.toEqual(config); + expect(signingClient.queryContractSmart).toHaveBeenCalledTimes(2); + }); + }); + + describe("refreshOracleParams", () => { + it("executes the refresh message with automatic gas", async () => { + const { client, signingClient } = setup(); + + await expect(client.refreshOracleParams()).resolves.toBe("ORDERED_TX_HASH"); + expect(signingClient.execute).toHaveBeenCalledWith( + PRIVATE_KEY_ADDRESS, + CONTRACT_ADDRESS, + { refresh_oracle_params: {} }, + "auto", + ); + }); + }); + + describe("updateFee", () => { + it("executes the fee update with automatic gas", async () => { + const { client, signingClient } = setup(); + + await expect(client.updateFee("500")).resolves.toBe("ORDERED_TX_HASH"); + expect(signingClient.execute).toHaveBeenCalledWith( + PRIVATE_KEY_ADDRESS, + CONTRACT_ADDRESS, + { update_fee: { new_fee: "500" } }, + "auto", + ); + }); + + it.each(["abc", "-100", "100.5", ""])("rejects the invalid fee %j without broadcasting anything", async (fee) => { + const { client, signingClient } = setup(); + + await expect(client.updateFee(fee)).rejects.toThrow("Invalid fee"); + expect(signingClient.execute).not.toHaveBeenCalled(); + }); + }); + + describe("transferAdmin", () => { + it("executes the admin transfer with automatic gas", async () => { + const { client, signingClient } = setup(); + const newAdmin = "akash19rl4cm2hmr8afy4kldpxz3fka4jguq0a3mq6x0"; + + await expect(client.transferAdmin(newAdmin)).resolves.toBe("ORDERED_TX_HASH"); + expect(signingClient.execute).toHaveBeenCalledWith( + PRIVATE_KEY_ADDRESS, + CONTRACT_ADDRESS, + { transfer_admin: { new_admin: newAdmin } }, + "auto", + ); + }); + + it.each(["not-a-valid-address", "", "cosmos1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5lzv7xu"])( + "rejects the invalid admin address %j without broadcasting anything", + async (address) => { + const { client, signingClient } = setup(); + + await expect(client.transferAdmin(address)).rejects.toThrow("Invalid address format"); + expect(signingClient.execute).not.toHaveBeenCalled(); + }, + ); + }); + + /** The surface the service actually uses: the cosmwasm execute/query methods plus the unordered-signing seam. */ + type ContractSigningClient = SigningClient & Pick; + + function setup(input?: Partial) { + // Gas simulation for the unordered path runs the real query pipeline, so stub the underlying ABCI query. + const abciQuery = vi.fn().mockResolvedValue({ + code: 0, + value: SimulateResponse.encode(SimulateResponse.fromPartial({ gasInfo: { gasUsed: 80_000n, gasWanted: 80_000n } })).finish(), + height: 1, + }); + const queryClient = new QueryClient(mock({ abciQuery })); + + // Encoding goes through a real registry, but behind a plain delegate: the mock proxies nested objects, and a proxied + // Registry breaks the internal Map its type lookup relies on. + const registry = new Registry([...defaultRegistryTypes, ["/cosmwasm.wasm.v1.MsgExecuteContract", MsgExecuteContract]]); + + const signingClient = mock({ + registry: { encodeAsAny: (message: EncodeObject) => registry.encodeAsAny(message) } as Registry, + execute: vi.fn().mockResolvedValue(mock({ transactionHash: "ORDERED_TX_HASH", gasUsed: 120_000n, gasWanted: 180_000n })), + queryContractSmart: vi.fn().mockResolvedValue({}), + getChainId: vi.fn().mockResolvedValue("akashnet-2"), + getAccount: vi.fn().mockResolvedValue({ address: PRIVATE_KEY_ADDRESS, accountNumber: 7, sequence: 3, pubkey: null } satisfies Account), + forceGetQueryClient: vi.fn(() => queryClient), + broadcastTx: vi.fn().mockResolvedValue(mock({ code: 0, transactionHash: "UNORDERED_TX_HASH", gasUsed: 90_000n })), + }); + + const connectWithSigner = vi.fn( + async () => signingClient as unknown as SigningCosmWasmClient, + ); + + const client = new ContractClientService({ + rpcEndpoint: RPC_ENDPOINT, + contractAddress: CONTRACT_ADDRESS, + denom: "uakt", + gasPrice: "0.025uakt", + gasMultiplier: 1.5, + walletSecret: { type: "privateKey", value: PRIVATE_KEY }, + priceUpdateTxMethod: "ordered", + unorderedTxTtlMs: 180_000, + smartContractConfigCacheTTLMs: 60_000, + ...input, + connectWithSigner, + }); + + const priceUpdate: PriceUpdate = { + priceData: { + id: "feed-id", + price: { price: "12345", conf: "10", expo: -8, publish_time: 1_700_000_000 }, + ema_price: { price: "12345", conf: "10", expo: -8, publish_time: 1_700_000_000 }, + }, + vaa: btoa("vaa-payload"), + }; + + return { client, signingClient, connectWithSigner, abciQuery, priceUpdate }; + } + + function createConfigResponse(): ConfigResponse { + return { + admin: MNEMONIC_ADDRESS, + wormhole_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 new file mode 100644 index 0000000..d72c75f --- /dev/null +++ b/src/services/contract-client/contract-client.service.ts @@ -0,0 +1,380 @@ +import { SigningCosmWasmClient } from "@cosmjs/cosmwasm-stargate"; +import { toUtf8 } from "@cosmjs/encoding"; +import { type AccountData, DirectSecp256k1HdWallet, DirectSecp256k1Wallet, type EncodeObject, type OfflineDirectSigner } from "@cosmjs/proto-signing"; +import { GasPrice, type DeliverTxResponse } from "@cosmjs/stargate"; +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"; + +export class ContractClientService implements PriceUpdater { + readonly #config: SigningClientServiceConfig; + + #smartContractConfig: { + expiresAt: number; + value?: Promise; + } = { expiresAt: 0 }; + + constructor(config: SigningClientServiceConfig) { + this.#config = config; + } + + #getSigner = memoizeAsync(async () => { + return await this.#createWallet(this.#config.walletSecret); + }); + + #getSigningClient = memoizeAsync(async () => { + const connectWithSigner = this.#config.connectWithSigner ?? SigningCosmWasmClient.connectWithSigner; + return await connectWithSigner( + this.#config.rpcEndpoint, + await this.#getSigner(), + { gasPrice: GasPrice.fromString(this.#config.gasPrice) }, + ); + }); + + #getSigningClientWithUnorderedTxSupport = memoizeAsync(async () => { + const [signingClient, signer] = await Promise.all([ + this.#getSigningClient(), + this.#getSigner(), + ]); + return new SigningStargateClientService(signingClient as unknown as SigningClient, signer, { + ttlMs: this.#config.unorderedTxTtlMs, + gasMultiplier: this.#config.gasMultiplier, + averageGasPrice: parseFloat(this.#config.gasPrice), + }); + }); + + async getAccount(): Promise { + const signer = await this.#getSigner(); + const [accountData] = await signer.getAccounts(); + if (!accountData) { + throw new Error("No accounts found in signer"); + } + + 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; + }> { + const msg: UpdatePriceFeedMsg = { + update_price_feed: { + vaa: priceUpdate.vaa, + }, + }; + + if (this.#config.priceUpdateTxMethod === "unordered") { + return await this.#updatePriceInUnorderedTx(msg, options); + } + + return await this.#updatePriceInOrderedTx(msg, options); + } + + async #updatePriceInUnorderedTx(msg: UpdatePriceFeedMsg, options: PriceUpdateOptions) { + const [signingClient, account] = await Promise.all([ + this.#getSigningClientWithUnorderedTxSupport(), + this.getAccount(), + ]); + + const messages: EncodeObject[] = [ + { + 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 }], + }), + }, + ]; + + const tx = await signingClient.signUnordered(messages); + const deliveredTx = await signingClient.broadcastTx(TxRaw.encode(tx).finish()); + if (deliveredTx.code !== 0) { + throw new BroadcastError(deliveredTx); + } + return { + transactionHash: deliveredTx.transactionHash, + gasUsed: BigInt(deliveredTx.gasUsed), + }; + } + + async #updatePriceInOrderedTx(msg: UpdatePriceFeedMsg, options: PriceUpdateOptions) { + const [signingClient, account] = await Promise.all([ + this.#getSigningClient(), + this.getAccount(), + ]); + const result = await signingClient.execute( + account.address, + this.#config.contractAddress, + msg, + this.#config.gasMultiplier, + undefined, + [{ denom: this.#config.denom, amount: options.updateFee }], + ); + return { + transactionHash: result.transactionHash, + gasUsed: result.gasUsed, + }; + } + + #createWallet(secret: SigningClientServiceConfig["walletSecret"]): Promise { + const prefix = "akash"; + if (secret.type === "mnemonic") { + return DirectSecp256k1HdWallet.fromMnemonic(secret.value, { prefix }); + } + + const privateKeyBytes = Buffer.from(secret.value, "hex"); + return DirectSecp256k1Wallet.fromKey(privateKeyBytes, prefix); + } + + async queryCurrentPrice(): Promise { + const signingClient = await this.#getSigningClient(); + const price: PriceResponse = await signingClient.queryContractSmart( + this.#config.contractAddress, + { get_price: {} }, + ); + + return price; + } + + async queryPriceFeed(): Promise { + const signingClient = await this.#getSigningClient(); + const feed: PriceFeedResponse = await signingClient.queryContractSmart( + this.#config.contractAddress, + { get_price_feed: {} }, + ); + + return feed; + } + + async queryConfig(): Promise { + if (!this.#smartContractConfig.value || Date.now() > this.#smartContractConfig.expiresAt) { + this.#smartContractConfig.expiresAt = Date.now() + this.#config.smartContractConfigCacheTTLMs; + this.#smartContractConfig.value = this.#getSigningClient() + .then(signingClient => signingClient.queryContractSmart( + this.#config.contractAddress, + { get_config: {} }, + )).catch((error) => { + this.#smartContractConfig.value = undefined; + this.#smartContractConfig.expiresAt = 0; + throw error; + }); + } + + const config = await this.#smartContractConfig.value; + return config; + } + + async queryOracleParams(): Promise { + const signingClient = await this.#getSigningClient(); + const params: OracleParamsResponse = await signingClient.queryContractSmart( + this.#config.contractAddress, + { get_oracle_params: {} }, + ); + + return params; + } + + async refreshOracleParams(): Promise { + const msg: RefreshOracleParamsMsg = { + refresh_oracle_params: {}, + }; + + const [signingClient, account] = await Promise.all([ + this.#getSigningClient(), + this.getAccount(), + ]); + const result = await signingClient.execute( + account.address, + this.#config.contractAddress, + msg, + "auto", + ); + + return result.transactionHash; + } + + async updateFee(newFee: string): Promise { + validateFeeAmount(newFee); + + const msg: UpdateFeeMsg = { + update_fee: { + new_fee: newFee, + }, + }; + + const [signingClient, account] = await Promise.all([ + this.#getSigningClient(), + this.getAccount(), + ]); + const result = await signingClient.execute( + account.address, + this.#config.contractAddress, + msg, + "auto", + ); + + return result.transactionHash; + } + + async transferAdmin(newAdmin: string): Promise { + validateAkashAddress(newAdmin); + + const msg: TransferAdminMsg = { + transfer_admin: { + new_admin: newAdmin, + }, + }; + + const [signingClient, account] = await Promise.all([ + this.#getSigningClient(), + this.getAccount(), + ]); + const result = await signingClient.execute( + account.address, + this.#config.contractAddress, + msg, + "auto", + ); + + return result.transactionHash; + } + + async disconnect(): Promise { + const signingClient = await this.#getSigningClient(); + this.#getSigningClient.cache.clear(); + this.#getSigningClientWithUnorderedTxSupport.cache.clear(); + signingClient.disconnect(); + } +} + +/** + * A tx the node accepted into a block but that failed while executing: it is on chain, it spent its fee, and it changed + * nothing. Distinct from a rejected broadcast, which never lands and throws out of `broadcastTx` itself. + * + * The `code` alone is not diagnosable, because every module numbers its own errors starting at 2 and the codespace that + * disambiguates them is dropped by cosmjs when it maps the indexed tx to a `DeliverTxResponse`. Code 5 is `insufficient + * funds` under the `sdk` codespace but `execute wasm contract failed` under `wasm`. What the chain does report is + * `rawLog`, the module's own error text, so carry that plus the hash needed to look the tx up on chain. + */ +export class BroadcastError extends Error { + readonly code: number; + + readonly rawLog: string; + + readonly transactionHash: string; + + constructor(deliveredTx: Pick) { + const rawLog = deliveredTx.rawLog ?? ""; + const detail = rawLog || "chain returned no log; query the tx by hash for the module error"; + super(`Broadcast failed with code ${deliveredTx.code} (tx ${deliveredTx.transactionHash}): ${detail}`); + this.name = "BroadcastError"; + this.code = deliveredTx.code; + this.rawLog = rawLog; + this.transactionHash = deliveredTx.transactionHash; + } +} + +export interface SigningClientServiceConfig { + rpcEndpoint: string; + contractAddress: string; + denom: string; + gasPrice: string; + gasMultiplier: number; + walletSecret: + | { + type: "mnemonic"; + /** mnemonic phrase for wallet */ + value: string; + } + | { + type: "privateKey"; + /** hex-encoded private key for wallet */ + value: string; + }; + priceUpdateTxMethod?: "ordered" | "unordered"; + unorderedTxTtlMs: number; + smartContractConfigCacheTTLMs: number; + /** + * Seam for establishing the RPC connection. + * @default SigningCosmWasmClient.connectWithSigner + */ + connectWithSigner?: typeof SigningCosmWasmClient.connectWithSigner; +} + +export interface ConfigResponse { + admin: string; + wormhole_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 { + price: string; // Uint128 serializes as string + conf: string; // Uint128 serializes as string + expo: number; // i32 + 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 + }; +} + +interface TransferAdminMsg { + transfer_admin: { + new_admin: string; + }; +} + +interface RefreshOracleParamsMsg { + refresh_oracle_params: Record; +} + +interface PriceFeedResponse { + symbol: string; + price: string; // Uint128 serializes as string + conf: string; // Uint128 serializes as string + expo: number; // i32 + publish_time: number; // i64 + prev_publish_time: number; // i64 +} + +interface OracleParamsResponse { + max_price_deviation_bps: number; // u64 + min_price_sources: number; // u32 + max_price_staleness_blocks: number; // i64 + twap_window: number; // i64 + last_updated_height: number; // u64 +} diff --git a/src/services/hermes-client/hermes-client.test.ts b/src/services/hermes-client/hermes-client.test.ts new file mode 100644 index 0000000..67cbe8b --- /dev/null +++ b/src/services/hermes-client/hermes-client.test.ts @@ -0,0 +1,750 @@ +import type { AccountData } from "@cosmjs/proto-signing"; +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 type { PriceUpdate, PriceProducerFactory, PriceProducerFactoryOptions } from "../../types.ts"; + +const CONTRACT_ADDRESS = "akash1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5lzv7xu"; +const ACCOUNT_ADDRESS = "akash1x0sxpqwzc4jzhqzlmvpqvgvjfnpqxwgvfnkjxs"; +const VALID_MNEMONIC = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + +// ============================================================ +// SEC-02: URL validation must be enforced on endpoints +// ============================================================ +describe("SEC-02: Endpoint URL validation in HermesClient", () => { + it("rejects HTTP RPC endpoints", () => { + expect(() => setup({ + rpcEndpoint: "http://insecure-rpc.example.com", + })).toThrow("only HTTPS endpoints are allowed"); + }); + + it("rejects SSRF-targeted RPC endpoints (localhost)", () => { + expect(() => setup({ + rpcEndpoint: "https://localhost:26657", + })).toThrow("private or internal addresses are not allowed"); + }); + + it("accepts valid HTTPS endpoints", () => { + const { client } = setup(); + expect(client).toBeDefined(); + }); +}); + +// ============================================================ +// SEC-04: Error messages must not leak implementation details +// ============================================================ +describe("SEC-04: Error message information leakage", () => { + it("updatePrice errors do not leak internal paths or stack traces", async () => { + const { client, contractClient } = setup(); + contractClient.updatePrice.mockRejectedValueOnce( + new Error("submit failed in /internal/src/hermes-client.ts\n at Object. (/app/node_modules/@cosmjs/stargate/index.js:1:1)"), + ); + + const result = await client.updatePrice().catch(error => ({ error })); + + expect(result).toHaveProperty("error"); + const { error } = result as { error: Error }; + expect(error.message).not.toMatch(/\/[^\s]+\.(ts|js)/); + expect(error.message).not.toContain("at "); + expect(error.message).not.toContain("node_modules"); + }); +}); + +// ============================================================ +// SEC-08: Config/status must not expose sensitive data +// ============================================================ +describe("SEC-08: Sensitive data in config exposure", () => { + it("getStatus must not include config object or mnemonic", async () => { + const { client } = setup(); + + const status = await client.getStatus(); + + expect(status).toEqual({ + isRunning: false, + contractAddress: CONTRACT_ADDRESS, + priceFeedId: "test-feed-id", + address: ACCOUNT_ADDRESS, + lastPriceUpdateReceivedAt: undefined, + lastPriceUpdateAt: undefined, + }); + expect(JSON.stringify(status)).not.toContain("abandon"); + }); +}); + +describe(HermesClient.name, () => { + describe("constructor", () => { + it("allows HTTP endpoints when unsafeAllowInsecureEndpoints is true", () => { + const { client } = setup({ + rpcEndpoint: "http://rpc.akashnet.net", + unsafeAllowInsecureEndpoints: true, + }); + expect(client).toBeDefined(); + }); + + it("allows private addresses when unsafeAllowInsecureEndpoints is true", () => { + const { client } = setup({ + rpcEndpoint: "http://localhost:26657", + unsafeAllowInsecureEndpoints: true, + }); + expect(client).toBeDefined(); + }); + + it("rejects invalid mnemonic word count", () => { + expect(() => setup({ + walletSecret: { type: "mnemonic", value: "abandon abandon abandon" }, + })).toThrow("Invalid mnemonic"); + }); + + it("rejects mnemonic with invalid characters", () => { + expect(() => setup({ + walletSecret: { type: "mnemonic", value: "Abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon About" }, + })).toThrow("Invalid mnemonic"); + }); + + it("accepts a valid private key", () => { + const { client } = setup({ + walletSecret: { type: "privateKey", value: "0000000000000000000000000000000000000000000000000000000000000001" }, + }); + expect(client).toBeDefined(); + }); + + it("rejects an invalid private key", () => { + expect(() => setup({ + walletSecret: { type: "privateKey", value: "not-a-valid-hex-key" }, + })).toThrow("Invalid private key"); + }); + + it("rejects an invalid contract address", () => { + expect(() => setup({ + contractAddress: "not-a-contract", + })).toThrow("Invalid contract address format"); + }); + }); + + describe("updatePrice()", () => { + it("passes the configured price feed id and the abort signal to the price producer", async () => { + const { client, priceProducerFactory } = setup(); + const ac = new AbortController(); + + await client.updatePrice({ signal: ac.signal }); + + expect(priceProducerFactory).toHaveBeenCalledWith(expect.objectContaining({ + priceFeedId: "test-feed-id", + signal: ac.signal, + })); + }); + + it("submits the VAA when the price is stale", async () => { + const { client, priceUpdate, contractClient } = setup({ + priceFeed: buildPriceFeed("12400", -2, 2000), + }); + contractClient.queryCurrentPrice.mockResolvedValue(buildCurrentPrice("12345", -2, 1000)); + + await client.updatePrice(); + + expect(contractClient.updatePrice).toHaveBeenCalledWith(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, + }); + + await client.updatePrice(); + + expect(contractClient.updatePrice).not.toHaveBeenCalled(); + expect(logger.log).toHaveBeenCalledWith(expect.stringContaining("no new price was available")); + }); + + it("skips update when price is already up to date", async () => { + const { client, contractClient, logger } = setup({ + priceFeed: buildPriceFeed("10000", -2, 2000), + }); + contractClient.queryCurrentPrice.mockResolvedValue(buildCurrentPrice("10000", -2, 2000)); + + await client.updatePrice(); + + expect(contractClient.updatePrice).not.toHaveBeenCalled(); + expect(logger.log).toHaveBeenCalledWith( + expect.stringContaining("already up to date"), + ); + }); + + it("skips update when contract has newer publish_time", async () => { + const { client, contractClient } = setup({ + priceFeed: buildPriceFeed("10000", -2, 2000), + }); + contractClient.queryCurrentPrice.mockResolvedValue(buildCurrentPrice("10000", -2, 9999999999)); + + await client.updatePrice(); + + expect(contractClient.updatePrice).not.toHaveBeenCalled(); + }); + + it("counts a skipped update", async () => { + const counterSpy = vi.spyOn(priceUpdateCounter, "add"); + const { client, contractClient } = setup({ + priceFeed: buildPriceFeed("10000", -2, 2000), + }); + contractClient.queryCurrentPrice.mockResolvedValue(buildCurrentPrice("10000", -2, 2000)); + + await client.updatePrice(); + + expect(counterSpy).toHaveBeenCalledWith(1, { result: "skipped" }); + counterSpy.mockRestore(); + }); + + it("counts a successful update", async () => { + const counterSpy = vi.spyOn(priceUpdateCounter, "add"); + const { client, contractClient } = setup({ + priceFeed: buildPriceFeed("10000", -2, 2000), + }); + contractClient.queryCurrentPrice.mockResolvedValue(buildCurrentPrice("9000", -2, 1000)); + + await client.updatePrice(); + + expect(counterSpy).toHaveBeenCalledWith(1, { result: "success" }); + counterSpy.mockRestore(); + }); + + describe("priceDeviationTolerance", () => { + it("skips update when absolute deviation is within tolerance", async () => { + const { client, contractClient, logger } = setup({ + priceDeviationTolerance: { type: "absolute", value: 1.0 }, + priceFeed: buildPriceFeed("10000", -2, 2000), + }); + contractClient.queryCurrentPrice.mockResolvedValue(buildCurrentPrice("10050", -2, 1000)); + + await client.updatePrice(); + + expect(contractClient.updatePrice).not.toHaveBeenCalled(); + expect(logger.log).toHaveBeenCalledWith( + expect.stringContaining("absolute tolerance"), + ); + }); + + it("updates when absolute deviation exceeds tolerance", async () => { + const { client, contractClient } = setup({ + priceDeviationTolerance: { type: "absolute", value: 1.0 }, + priceFeed: buildPriceFeed("10000", -2, 2000), + }); + contractClient.queryCurrentPrice.mockResolvedValue(buildCurrentPrice("10200", -2, 1000)); + + await client.updatePrice(); + + expect(contractClient.updatePrice).toHaveBeenCalledTimes(1); + }); + + it("skips update when absolute deviation equals tolerance exactly", async () => { + const { client, contractClient, logger } = setup({ + priceDeviationTolerance: { type: "absolute", value: 1.0 }, + priceFeed: buildPriceFeed("10000", -2, 2000), + }); + contractClient.queryCurrentPrice.mockResolvedValue(buildCurrentPrice("10100", -2, 1000)); + + await client.updatePrice(); + + expect(contractClient.updatePrice).not.toHaveBeenCalled(); + expect(logger.log).toHaveBeenCalledWith( + expect.stringContaining("absolute tolerance"), + ); + }); + + it("skips update when percentage deviation is within tolerance", async () => { + const { client, contractClient, logger } = setup({ + priceDeviationTolerance: { type: "percentage", value: 1 }, + priceFeed: buildPriceFeed("10000", -2, 2000), + }); + contractClient.queryCurrentPrice.mockResolvedValue(buildCurrentPrice("10050", -2, 1000)); + + await client.updatePrice(); + + expect(contractClient.updatePrice).not.toHaveBeenCalled(); + expect(logger.log).toHaveBeenCalledWith( + expect.stringContaining("percentage tolerance"), + ); + }); + + it("updates when percentage deviation exceeds tolerance", async () => { + const { client, contractClient } = setup({ + priceDeviationTolerance: { type: "percentage", value: 1 }, + priceFeed: buildPriceFeed("10000", -2, 2000), + }); + contractClient.queryCurrentPrice.mockResolvedValue(buildCurrentPrice("10500", -2, 1000)); + + await client.updatePrice(); + + expect(contractClient.updatePrice).toHaveBeenCalledTimes(1); + }); + + it("skips update when percentage deviation equals tolerance exactly", async () => { + const { client, contractClient, logger } = setup({ + priceDeviationTolerance: { type: "percentage", value: 1 }, + priceFeed: buildPriceFeed("10100", -2, 2000), + }); + contractClient.queryCurrentPrice.mockResolvedValue(buildCurrentPrice("10000", -2, 1000)); + + await client.updatePrice(); + + expect(contractClient.updatePrice).not.toHaveBeenCalled(); + expect(logger.log).toHaveBeenCalledWith( + expect.stringContaining("percentage tolerance"), + ); + }); + + it("updates on any price difference with default tolerance (absolute 0)", async () => { + const { client, contractClient } = setup({ + priceFeed: buildPriceFeed("10001", -2, 2000), + }); + contractClient.queryCurrentPrice.mockResolvedValue(buildCurrentPrice("10000", -2, 1000)); + + await client.updatePrice(); + + expect(contractClient.updatePrice).toHaveBeenCalledTimes(1); + }); + + it("handles different exponents between new and current price", async () => { + const { client, contractClient } = setup({ + priceDeviationTolerance: { type: "absolute", value: 1.0 }, + priceFeed: buildPriceFeed("1000000", -4, 2000), + }); + contractClient.queryCurrentPrice.mockResolvedValue(buildCurrentPrice("10200", -2, 1000)); + + await client.updatePrice(); + + expect(contractClient.updatePrice).toHaveBeenCalledTimes(1); + }); + + it("handles zero current price when calculating percentage deviation", async () => { + const { client, contractClient } = setup({ + priceDeviationTolerance: { type: "percentage", value: 10 }, + priceFeed: buildPriceFeed("10000", -2, 2000), + }); + contractClient.queryCurrentPrice.mockResolvedValue(buildCurrentPrice("0", -2, 1000)); + + await client.updatePrice(); + + expect(contractClient.updatePrice).toHaveBeenCalledTimes(1); + }); + }); + + it("records price staleness on successful update", async () => { + const stalenessSpy = vi.spyOn(blockchainPriceStaleness, "record"); + const { client, contractClient } = setup({ + priceFeed: buildPriceFeed("10000", -2, 2000), + }); + contractClient.queryCurrentPrice.mockResolvedValue(buildCurrentPrice("9000", -2, 1000)); + + await client.updatePrice(); + + expect(stalenessSpy).toHaveBeenCalledWith(1000); + stalenessSpy.mockRestore(); + }); + + it("records price staleness on skipped update", async () => { + const stalenessSpy = vi.spyOn(blockchainPriceStaleness, "record"); + const { client, contractClient } = setup({ + priceFeed: buildPriceFeed("10000", -2, 2000), + }); + contractClient.queryCurrentPrice.mockResolvedValue(buildCurrentPrice("10000", -2, 2000)); + + await client.updatePrice(); + + expect(stalenessSpy).toHaveBeenCalledWith(0); + stalenessSpy.mockRestore(); + }); + + it("records error_code attribute and staleness on failure", async () => { + const counterSpy = vi.spyOn(priceUpdateCounter, "add"); + const stalenessSpy = vi.spyOn(blockchainPriceStaleness, "record"); + const { client, contractClient } = setup({ + priceFeed: buildPriceFeed("10000", -2, 2000), + }); + contractClient.queryCurrentPrice.mockResolvedValue(buildCurrentPrice("9000", -2, 1000)); + contractClient.updatePrice.mockRejectedValueOnce(new Error("insufficient funds")); + + await client.updatePrice().catch(() => {}); + + expect(counterSpy).toHaveBeenCalledWith(1, { result: "failure", error_code: "insufficient_balance" }); + expect(stalenessSpy).toHaveBeenCalledWith(1000); + counterSpy.mockRestore(); + stalenessSpy.mockRestore(); + }); + + it("records the timestamp of the last successful update", async () => { + const { client, contractClient } = setup({ + priceFeed: buildPriceFeed("10000", -2, 2000), + }); + contractClient.queryCurrentPrice.mockResolvedValue(buildCurrentPrice("9000", -2, 1000)); + + await client.updatePrice(); + + const status = await client.getStatus(); + expect(status.lastPriceUpdateAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + }); + + describe("insufficient balance cooldown", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("skips submissions during the cooldown and retries once it expires", async () => { + vi.useFakeTimers(); + const { client, contractClient, logger } = setup({ + priceFeed: buildPriceFeed("10000", -2, 2000), + insufficientBalanceRetryDelayMs: 5000, + }); + contractClient.queryCurrentPrice.mockResolvedValue(buildCurrentPrice("9000", -2, 1000)); + contractClient.updatePrice.mockRejectedValueOnce(new Error("insufficient funds")); + + await client.updatePrice().catch(() => {}); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("insufficient balance cooldown for 5000ms")); + + // Within the cooldown window the submission is skipped entirely. + await client.updatePrice(); + expect(contractClient.updatePrice).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("cooldown active")); + + vi.advanceTimersByTime(5000); + await client.updatePrice(); + + expect(logger.log).toHaveBeenCalledWith(expect.stringContaining("cooldown expired")); + expect(contractClient.updatePrice).toHaveBeenCalledTimes(2); + }); + + it("clears the cooldown after a successful submission", async () => { + vi.useFakeTimers(); + const { client, contractClient } = setup({ + priceFeed: buildPriceFeed("10000", -2, 2000), + insufficientBalanceRetryDelayMs: 5000, + }); + contractClient.queryCurrentPrice.mockResolvedValue(buildCurrentPrice("9000", -2, 1000)); + contractClient.updatePrice.mockRejectedValueOnce(new Error("insufficient funds")); + + await client.updatePrice().catch(() => {}); + vi.advanceTimersByTime(5000); + await client.updatePrice(); + await client.updatePrice(); + + expect(contractClient.updatePrice).toHaveBeenCalledTimes(3); + }); + }); + }); + + describe("start()", () => { + it("starts once if called concurrently", async () => { + const priceUpdate = buildPriceFeed("12345", -2, 2000); + const factory = blockingFactory(priceUpdate); + const { client, contractClient } = setup({ priceProducerFactory: factory }); + const start = client.start.bind(client); + const abortController = new AbortController(); + + contractClient.queryCurrentPrice.mockResolvedValue(buildCurrentPrice("9000", -2, 1000)); + + const allPromise = Promise.all([ + start({ signal: abortController.signal }), + start({ signal: abortController.signal }), + start({ signal: abortController.signal }), + start({ signal: abortController.signal }), + ]); + + await vi.waitFor(() => { + expect(contractClient.updatePrice).toHaveBeenCalledTimes(1); + }); + + abortController.abort(); + await allPromise; + + expect(factory).toHaveBeenCalledTimes(1); + }); + + it("logs and returns when already running", async () => { + const factory = blockingFactory(buildPriceFeed("10000", -2, 2000)); + const { client, logger } = setup({ priceProducerFactory: factory }); + const abortController = new AbortController(); + + const startPromise = client.start({ signal: abortController.signal }); + await vi.waitFor(async () => { + expect((await client.getStatus()).isRunning).toBe(true); + }); + await client.start({ signal: abortController.signal }); + + expect(logger.log).toHaveBeenCalledWith("Hermes client is already running"); + + abortController.abort(); + await startPromise; + }); + + it("returns immediately when the signal is already aborted", async () => { + const { client, priceProducerFactory } = setup(); + + await client.start({ signal: AbortSignal.abort() }); + + expect(priceProducerFactory).not.toHaveBeenCalled(); + expect((await client.getStatus()).isRunning).toBe(false); + }); + + it("feeds the producer with the price feed id from the contract configuration", async () => { + const { client, priceProducerFactory, contractClient } = setup(); + contractClient.queryConfig.mockResolvedValue(buildConfig({ price_feed_id: "other-feed-id" })); + + const abortController = new AbortController(); + await client.start({ signal: abortController.signal }); + abortController.abort(); + + expect(priceProducerFactory).toHaveBeenCalledWith(expect.objectContaining({ + priceFeedId: "other-feed-id", + })); + }); + + it("stops when abort signal fires", async () => { + const factory = blockingFactory(buildPriceFeed("10000", -2, 2000)); + const { client, logger } = setup({ priceProducerFactory: factory }); + const ac = new AbortController(); + + const startPromise = client.start({ signal: ac.signal }); + await vi.waitFor(async () => { + expect((await client.getStatus()).isRunning).toBe(true); + }); + + ac.abort(); + await startPromise; + + expect((await client.getStatus()).isRunning).toBe(false); + expect(logger.log).toHaveBeenCalledWith("Hermes client stopped"); + }); + + it("continues running when updatePrice throws", async () => { + const { client, contractClient, logger } = setup(); + const abortController = new AbortController(); + + contractClient.queryCurrentPrice.mockRejectedValue(new Error("query failed")); + + await client.start({ signal: abortController.signal }); + abortController.abort(); + + 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); + throw new Error("stream broke"); + }); + const { client, contractClient } = setup({ priceProducerFactory: factory as unknown as PriceProducerFactory }); + contractClient.queryCurrentPrice.mockResolvedValue(buildCurrentPrice("9000", -2, 1000)); + + const ac = new AbortController(); + await expect(client.start({ signal: ac.signal })).rejects.toThrow("Failed to start Hermes client"); + ac.abort(); + + expect(contractClient.updatePrice).toHaveBeenCalledTimes(1); + }); + + it("rejects when the contract configuration cannot be fetched", async () => { + const { client, contractClient } = setup(); + + contractClient.queryConfig.mockRejectedValueOnce(new Error("connection refused")); + + const ac = new AbortController(); + try { + await expect(client.start({ signal: ac.signal })).rejects.toThrow("Failed to initialize Hermes client: connection refused"); + } finally { + ac.abort(); + } + }); + + it("sets lastPriceUpdateReceivedAt in ISO-8601 format after receiving a price update", async () => { + const factory = blockingFactory(buildPriceFeed("10000", -2, 2000)); + const { client, contractClient } = setup({ priceProducerFactory: factory }); + const ac = new AbortController(); + + contractClient.queryCurrentPrice.mockResolvedValue(buildCurrentPrice("9000", -2, 1000)); + + const startPromise = client.start({ signal: ac.signal }); + await vi.waitFor(async () => { + const status = await client.getStatus(); + expect(status.lastPriceUpdateReceivedAt).toBeDefined(); + }); + + const status = await client.getStatus(); + expect(status.lastPriceUpdateReceivedAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + + ac.abort(); + await startPromise; + }); + + it("lastPriceUpdateReceivedAt is undefined before any price update is received", async () => { + const { client } = setup(); + + const status = await client.getStatus(); + expect(status.lastPriceUpdateReceivedAt).toBeUndefined(); + }); + + it("drops updates that arrive while a price update is in flight, keeping the latest", async () => { + const priceUpdate1 = buildPriceFeed("10000", -2, 2000, "vaa-1"); + const priceUpdate2 = buildPriceFeed("10100", -2, 3000, "vaa-2"); + const priceUpdate3 = buildPriceFeed("10200", -2, 4000, "vaa-3"); + // Hold the first on-chain update open so updates 2 and 3 both arrive while it is in flight. + const { promise: firstUpdateInFlight, resolve: releaseFirstUpdate } = Promise.withResolvers(); + const { promise: firstUpdateStarted, resolve: firstUpdateReached } = Promise.withResolvers(); + const factory = vi.fn(async function* () { + yield priceUpdate1; + await firstUpdateStarted; + yield priceUpdate2; + yield priceUpdate3; + releaseFirstUpdate(); + }); + const { client, contractClient } = setup({ priceProducerFactory: factory as unknown as PriceProducerFactory }); + + contractClient.queryCurrentPrice.mockResolvedValue(buildCurrentPrice("9000", -2, 1000)); + const receipt = { transactionHash: "TX", gasUsed: 500000n }; + contractClient.updatePrice + .mockImplementationOnce(async () => { + firstUpdateReached(); + await firstUpdateInFlight; + return receipt; + }) + .mockResolvedValue(receipt); + + const ac = new AbortController(); + await client.start({ signal: ac.signal }); + ac.abort(); + + const submittedVaas = contractClient.updatePrice.mock.calls.map(([update]) => update.vaa); + expect(submittedVaas).toEqual([btoa("vaa-1"), btoa("vaa-3")]); + }); + }); + + describe("classifyError()", () => { + it('returns "insufficient_balance" for insufficient funds error', () => { + expect(classifyError(new Error("insufficient funds: 100uakt < 1000uakt"))).toBe("insufficient_balance"); + }); + + it('returns "insufficient_balance" for insufficient fee error', () => { + expect(classifyError(new Error("insufficient fee"))).toBe("insufficient_balance"); + }); + + it('returns "insufficient_balance" for a failed unordered broadcast that ran out of funds', () => { + // The unordered path reports an on-chain failure as a BroadcastError; without its raw log the code alone is + // unclassifiable (sdk 5 is insufficient funds, wasm 5 is a contract revert) and the cooldown would never engage. + const error = new BroadcastError({ + code: 5, + rawLog: "failed to execute message; message index: 0: spendable balance 100uakt is smaller than 250uakt: insufficient funds", + transactionHash: "FAILED_TX_HASH", + }); + + expect(classifyError(error)).toBe("insufficient_balance"); + }); + + it('returns "timeout" for timeout error', () => { + expect(classifyError(new Error("request timeout"))).toBe("timeout"); + }); + + it('returns "timeout" for ETIMEDOUT error', () => { + expect(classifyError(new Error("connect ETIMEDOUT 1.2.3.4:443"))).toBe("timeout"); + }); + + it('returns "connection_issue" for ECONNREFUSED error', () => { + expect(classifyError(new Error("connect ECONNREFUSED 127.0.0.1:26657"))).toBe("connection_issue"); + }); + + it('returns "connection_issue" for ECONNRESET error', () => { + expect(classifyError(new Error("read ECONNRESET"))).toBe("connection_issue"); + }); + + it('returns "connection_issue" for ENOTFOUND error', () => { + expect(classifyError(new Error("getaddrinfo ENOTFOUND rpc.example.com"))).toBe("connection_issue"); + }); + + it('returns "unknown" for unrecognized errors', () => { + expect(classifyError(new Error("something unexpected"))).toBe("unknown"); + }); + + it('returns "unknown" for non-Error values', () => { + expect(classifyError("string error")).toBe("unknown"); + }); + }); +}); + +function setup(input?: Partial & { + priceFeed?: PriceUpdate; +}) { + const priceUpdate: PriceUpdate = input?.priceFeed ?? buildPriceFeed("123.45", -8, 1234567890); + + const priceProducerFactory = vi.fn(async function* () { + yield priceUpdate; + }); + + const contractClient = mock(); + contractClient.getAccount.mockResolvedValue(buildAccount()); + contractClient.queryConfig.mockResolvedValue(buildConfig()); + contractClient.queryCurrentPrice.mockResolvedValue(buildCurrentPrice("0", -2, 0)); + contractClient.updatePrice.mockResolvedValue({ transactionHash: "TX_DEFAULT", gasUsed: 500000n }); + + const logger = mock(); + const client = new HermesClient({ + rpcEndpoint: input?.rpcEndpoint ?? "https://rpc.akashnet.net:443", + contractAddress: input?.contractAddress ?? CONTRACT_ADDRESS, + walletSecret: input?.walletSecret ?? { type: "mnemonic", value: VALID_MNEMONIC }, + gasPrice: input?.gasPrice ?? "0.025uakt", + logger, + unsafeAllowInsecureEndpoints: input?.unsafeAllowInsecureEndpoints, + priceDeviationTolerance: input?.priceDeviationTolerance ?? { type: "absolute", value: 0 }, + priceProducerFactory: (input?.priceProducerFactory ?? priceProducerFactory) as PriceProducerFactory, + smartContractConfigCacheTTLMs: input?.smartContractConfigCacheTTLMs ?? 60_000, + unorderedTxTtlMs: input?.unorderedTxTtlMs ?? 180_000, + insufficientBalanceRetryDelayMs: input?.insufficientBalanceRetryDelayMs, + contractClientFactory: () => contractClient, + }); + + return { client, priceUpdate, priceProducerFactory, logger, contractClient }; +} + +function buildPriceFeed(price: string, expo: number, publishTime: number, vaa = "vaa-data"): PriceUpdate { + return { + priceData: { + id: "test-id", + price: { price, conf: "10", expo, publish_time: publishTime }, + ema_price: { price, conf: "10", expo, publish_time: publishTime }, + }, + vaa: btoa(vaa), + }; +} + +function buildCurrentPrice(price: string, expo: number, publishTime: number): PriceResponse { + return { price, conf: "10", expo, publish_time: publishTime }; +} + +function buildConfig(overrides?: Partial): ConfigResponse { + return { + admin: "akash1admin", + wormhole_contract: "akash1wormhole", + update_fee: "1", + price_feed_id: "test-feed-id", + default_denom: "uakt", + default_base_denom: "akt", + data_sources: [], + ...overrides, + }; +} + +function buildAccount(): AccountData { + return { address: ACCOUNT_ADDRESS, algo: "secp256k1", pubkey: new Uint8Array(33) }; +} + +function blockingFactory(priceUpdate: PriceUpdate) { + return vi.fn(async function* ({ signal }: PriceProducerFactoryOptions) { + yield priceUpdate; + if (signal && !signal.aborted) { + await new Promise(resolve => { + signal.addEventListener("abort", () => resolve(), { once: true }); + }); + } + }); +} diff --git a/src/hermes-client.ts b/src/services/hermes-client/hermes-client.ts similarity index 52% rename from src/hermes-client.ts rename to src/services/hermes-client/hermes-client.ts index 49d1720..2118679 100644 --- a/src/hermes-client.ts +++ b/src/services/hermes-client/hermes-client.ts @@ -11,176 +11,83 @@ * 4. Relays validated price to x/oracle module */ -import { SigningCosmWasmClient } from "@cosmjs/cosmwasm-stargate"; -import { DirectSecp256k1HdWallet, DirectSecp256k1Wallet, type OfflineDirectSigner } from "@cosmjs/proto-signing"; -import { GasPrice } from "@cosmjs/stargate"; -import { latestValue } from "./lib/generators/latest-value/latest-value.ts"; -import { blockchainPriceStaleness, priceUpdateCounter } from "./metrics.ts"; -import type { Logger, PriceProducerFactory, PriceUpdate, PriceUpdater, PythPriceData } from "./types.ts"; +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 type { Logger, PriceProducerFactory, PriceUpdate, PythPriceData } from "../../types.ts"; import { sanitizeErrorMessage, - validateAkashAddress, validateContractAddress, validateEndpointUrl, - validateFeeAmount, validateWalletSecret, -} from "./validation.ts"; +} from "../../validation.ts"; export interface HermesConfig { /** - * Allows insecure endpoint URLs (HTTP, private/internal addresses). - * @default false - */ + * Allows insecure endpoint URLs (HTTP, private/internal addresses). + * @default false + */ unsafeAllowInsecureEndpoints?: boolean; rpcEndpoint: string; contractAddress: string; - walletSecret: - | { - type: "mnemonic"; - /** mnemonic phrase for wallet */ - value: string; - } - | { - type: "privateKey"; - /** hex-encoded private key for wallet */ - value: string; - }; - denom?: string; - gasPrice?: string; + walletSecret: SigningClientServiceConfig["walletSecret"]; + + priceUpdateTxMethod: "ordered" | "unordered"; + denom: string; + gasPrice: string; + gasMultiplier: number; smartContractConfigCacheTTLMs: number; + unorderedTxTtlMs: number; /** - * Optional threshold for skipping updates when the price change is below a tolerance. - * - * - For `type: "absolute"`, `value` is an absolute price difference in quote currency units - * (e.g. `0.5` means $0.50 if the quote currency is USD). - * - For `type: "percentage"`, `value` is a number from 0 to 100 - * (e.g. `10` = 10%, `0.1` = 0.1%). - */ + * Optional threshold for skipping updates when the price change is below a tolerance. + * + * - For `type: "absolute"`, `value` is an absolute price difference in quote currency units + * (e.g. `0.5` means $0.50 if the quote currency is USD). + * - For `type: "percentage"`, `value` is a number from 0 to 100 + * (e.g. `10` = 10%, `0.1` = 0.1%). + */ priceDeviationTolerance?: { type: "absolute" | "percentage"; value: number; }; /** - * Factory function to create a price producer (AsyncGenerator) that yields price updates. - * This allows for different implementations of price fetching logic (e.g. polling, SSE). - */ + * Factory function to create a price producer (AsyncGenerator) that yields price updates. + * This allows for different implementations of price fetching logic (e.g. polling, SSE). + */ priceProducerFactory: PriceProducerFactory; /** - * Factory function to create a PriceUpdater instance for submitting price updates to the chain. - */ - priceUpdaterFactory: (client: SigningCosmWasmClient, signer: OfflineDirectSigner) => PriceUpdater; - /** - * Optional logger for informational messages. Should implement log, error, and warn methods. - */ + * Optional logger for informational messages. Should implement log, error, and warn methods. + */ logger?: Logger; /** - * Optional custom connectWithSigner function for testing or advanced use cases. Defaults to SigningCosmWasmClient.connectWithSigner. - */ - connectWithSigner?: typeof SigningCosmWasmClient.connectWithSigner; - /** - * Delay in milliseconds between submission retries when insufficient balance is detected. - * @default 60000 - */ + * Delay in milliseconds between submission retries when insufficient balance is detected. + * @default 60000 + */ insufficientBalanceRetryDelayMs?: number; + /** + * Creates the client used to talk to the chain. Defaults to a real {@link ContractClientService}, + * which opens an RPC connection; inject a fake to drive the client without a chain. + */ + contractClientFactory?: (config: SigningClientServiceConfig) => ContractClient; } -// ===================== -// Contract Execute Messages -// Matches pyth contract msg.rs -// ===================== - -interface UpdateFeeMsg { - update_fee: { - new_fee: string; // Uint256 serializes as string in JSON - }; -} - -interface TransferAdminMsg { - transfer_admin: { - new_admin: string; - }; -} - -interface RefreshOracleParamsMsg { - refresh_oracle_params: Record; -} - -// ===================== -// Contract Query Responses -// Matches Pyth contract msg.rs -// ===================== - -interface DataSourceResponse { - emitter_chain: number; // u16 - Wormhole chain ID (26 for Pythnet) - emitter_address: string; // 32 bytes hex encoded -} - -interface ConfigResponse { - admin: string; - wormhole_contract: string; - update_fee: string; // Uint256 serializes as string - price_feed_id: string; - default_denom: string; - default_base_denom: string; - data_sources: DataSourceResponse[]; -} - -interface PriceResponse { - price: string; // Uint128 serializes as string - conf: string; // Uint128 serializes as string - expo: number; // i32 - publish_time: number; // i64 -} - -interface PriceFeedResponse { - symbol: string; - price: string; // Uint128 serializes as string - conf: string; // Uint128 serializes as string - expo: number; // i32 - publish_time: number; // i64 - prev_publish_time: number; // i64 -} - -interface PriceFeedIdResponse { - price_feed_id: string; -} - -interface OracleParamsResponse { - max_price_deviation_bps: number; // u64 - min_price_sources: number; // u32 - max_price_staleness_blocks: number; // i64 - twap_window: number; // i64 - last_updated_height: number; // u64 -} +/** + * The chain access this client depends on. A subset of {@link ContractClientService}. + */ +export type ContractClient = Pick; const DEFAULT_PRICE_DEVIATION_TOLERANCE: Required["priceDeviationTolerance"] = { type: "absolute", value: 0 }; export class HermesClient { - #cosmClient?: SigningCosmWasmClient; - #wallet?: OfflineDirectSigner; - #senderAddress?: string; - readonly #config: Required> & { - gasPrice: GasPrice; - }; + readonly #signingClient: ContractClient; + readonly #config: Required>; #isRunning = false; #insufficientBalanceCooldownUntil: number | null = null; #lastPriceReceivedAt?: string; #lastPriceUpdateAt?: string; #logger: Exclude; - #connectWithSigner: typeof SigningCosmWasmClient.connectWithSigner; - #smartContractConfig: { - expiresAt: number; - value?: Promise; - } = { expiresAt: 0 }; - - static async connect(config: HermesConfig): Promise { - const client = new HermesClient(config); - await client.initialize(); - return client; - } - #priceUpdater?: PriceUpdater; constructor(config: HermesConfig) { const unsafeAllowInsecureEndpoints = config.unsafeAllowInsecureEndpoints ?? false; @@ -191,41 +98,33 @@ export class HermesClient { this.#config = { ...config, - denom: config.denom ?? "uakt", - gasPrice: GasPrice.fromString(config.gasPrice ?? "0.025uakt"), - unsafeAllowInsecureEndpoints, priceDeviationTolerance: config.priceDeviationTolerance ?? DEFAULT_PRICE_DEVIATION_TOLERANCE, insufficientBalanceRetryDelayMs: config.insufficientBalanceRetryDelayMs ?? 60_000, }; this.#logger = config.logger ?? console; - this.#connectWithSigner = config.connectWithSigner ?? SigningCosmWasmClient.connectWithSigner; + const createContractClient = config.contractClientFactory ?? (contractClientConfig => new ContractClientService(contractClientConfig)); + this.#signingClient = createContractClient({ + rpcEndpoint: config.rpcEndpoint, + walletSecret: config.walletSecret, + contractAddress: config.contractAddress, + denom: config.denom, + gasPrice: config.gasPrice, + gasMultiplier: config.gasMultiplier, + priceUpdateTxMethod: config.priceUpdateTxMethod, + unorderedTxTtlMs: config.unorderedTxTtlMs, + smartContractConfigCacheTTLMs: config.smartContractConfigCacheTTLMs, + }); } - /** - * Initialize the client and connect to the chain - */ - async initialize(): Promise { + async #initialize(): Promise { try { this.#logger.log("Initializing Hermes client..."); - this.#wallet = await this.#createWallet(this.#config.walletSecret); - - const [account] = await this.#wallet.getAccounts(); - this.#senderAddress = account.address; - this.#logger.log(`Using address: ${this.#senderAddress}`); - - this.#cosmClient = await this.#connectWithSigner( - this.#config.rpcEndpoint, - this.#wallet, - { - gasPrice: this.#config.gasPrice, - }, - ); - - this.#logger.log("Connected to chain successfully"); + const account = await this.#signingClient.getAccount(); + this.#logger.log(`Using address: ${account.address}`); this.#logger.log("Fetching smart contract configuration..."); - const smartContractConfig = await this.queryConfig(); + const smartContractConfig = await this.#signingClient.queryConfig(); this.#logger.log(`Using Pyth Price Feed ID: ${smartContractConfig.price_feed_id}`); this.#logger.log(`Update fee: ${smartContractConfig.update_fee} ${this.#config.denom}`); @@ -238,160 +137,10 @@ export class HermesClient { } } - #createWallet(secret: HermesConfig["walletSecret"]): Promise { - const prefix = "akash"; - if (secret.type === "mnemonic") { - return DirectSecp256k1HdWallet.fromMnemonic(secret.value, { prefix }); - } - - const privateKeyBytes = Buffer.from(secret.value, "hex"); - return DirectSecp256k1Wallet.fromKey(privateKeyBytes, prefix); - } - - #getCosmClient(): SigningCosmWasmClient { - if (!this.#cosmClient) { - throw new Error("Client not initialized"); - } - return this.#cosmClient; - } - - /** - * Query current price from contract - */ - async queryCurrentPrice(): Promise { - const price: PriceResponse = await this.#getCosmClient().queryContractSmart( - this.#config.contractAddress, - { get_price: {} }, - ); - - return price; - } - - /** - * Query current price feed with metadata from contract - */ - async queryPriceFeed(): Promise { - const feed: PriceFeedResponse = await this.#getCosmClient().queryContractSmart( - this.#config.contractAddress, - { get_price_feed: {} }, - ); - - return feed; - } - - /** - * Query contract configuration - */ - async queryConfig(): Promise { - if (!this.#smartContractConfig.value || Date.now() > this.#smartContractConfig.expiresAt) { - this.#smartContractConfig.expiresAt = Date.now() + this.#config.smartContractConfigCacheTTLMs; - this.#smartContractConfig.value = this.#getCosmClient().queryContractSmart( - this.#config.contractAddress, - { get_config: {} }, - ).catch((error) => { - this.#smartContractConfig.value = undefined; - this.#smartContractConfig.expiresAt = 0; - throw error; - }); - } - - const config = await this.#smartContractConfig.value; - - return config; - } - - /** - * Query cached oracle parameters from contract - */ - async queryOracleParams(): Promise { - const params: OracleParamsResponse = await this.#getCosmClient().queryContractSmart( - this.#config.contractAddress, - { get_oracle_params: {} }, - ); - - return params; - } - - /** - * Refresh cached oracle parameters (admin only) - */ - async refreshOracleParams(): Promise { - if (!this.#senderAddress) { - throw new Error("Client not initialized"); - } - - const msg: RefreshOracleParamsMsg = { - refresh_oracle_params: {}, - }; - - const result = await this.#getCosmClient().execute( - this.#senderAddress, - this.#config.contractAddress, - msg, - "auto", - ); - - return result.transactionHash; - } - - /** - * Update the update fee (admin only) - */ - async updateFee(newFee: string): Promise { - // SEC-06: Validate fee format before any operation - validateFeeAmount(newFee); - - if (!this.#senderAddress) { - throw new Error("Client not initialized"); - } - - const msg: UpdateFeeMsg = { - update_fee: { - new_fee: newFee, - }, - }; - - const result = await this.#getCosmClient().execute( - this.#senderAddress, - this.#config.contractAddress, - msg, - "auto", - ); - - return result.transactionHash; - } - - /** - * Transfer admin rights (admin only) - */ - async transferAdmin(newAdmin: string): Promise { - // SEC-05: Validate address format before any operation - validateAkashAddress(newAdmin); - - if (!this.#senderAddress) { - throw new Error("Client not initialized"); - } - - const msg: TransferAdminMsg = { - transfer_admin: { - new_admin: newAdmin, - }, - }; - - const result = await this.#getCosmClient().execute( - this.#senderAddress, - this.#config.contractAddress, - msg, - "auto", - ); - - return result.transactionHash; - } - async updatePrice(options?: { signal?: AbortSignal; }): Promise { - const smartCotractConfig = await this.queryConfig(); + const smartCotractConfig = await this.#signingClient.queryConfig(); const priceStream = this.#config.priceProducerFactory({ priceFeedId: smartCotractConfig.price_feed_id, logger: this.#logger, @@ -419,10 +168,6 @@ export class HermesClient { * 4. Contract verifies VAA via Wormhole, parses Pyth payload, relays to x/oracle */ async #updatePrice(priceUpdate: PriceUpdate): Promise { - if (!this.#senderAddress || !this.#wallet) { - throw new Error("Client not initialized"); - } - if (this.#insufficientBalanceCooldownUntil !== null) { if (Date.now() < this.#insufficientBalanceCooldownUntil) { this.#logger.warn("Skipping price update: insufficient balance cooldown active"); @@ -434,7 +179,7 @@ export class HermesClient { const startTime = performance.now(); try { - const currentPrice = await this.queryCurrentPrice(); + const currentPrice = await this.#signingClient.queryCurrentPrice(); const staleness = priceUpdate.priceData.price.publish_time - currentPrice.publish_time; blockchainPriceStaleness.record(staleness); @@ -444,17 +189,12 @@ export class HermesClient { return; } - const config = await this.queryConfig(); + const config = await this.#signingClient.queryConfig(); this.#logger.log("Submitting VAA to Pyth contract..."); this.#logger.log(` Wormhole contract: ${config.wormhole_contract}`); - this.#priceUpdater ??= this.#config.priceUpdaterFactory(this.#getCosmClient(), this.#wallet); - const result = await this.#priceUpdater.updatePrice(priceUpdate, { - senderAddress: this.#senderAddress, - contractAddress: this.#config.contractAddress, - denom: this.#config.denom, + const result = await this.#signingClient.updatePrice(priceUpdate, { updateFee: config.update_fee, - gasPrice: this.#config.gasPrice, }); const price = priceUpdate.priceData.price; @@ -540,6 +280,8 @@ export class HermesClient { // important to be set before any async operation to prevent multiple concurrent starts this.#isRunning = true; + + await this.#initialize(); signal.addEventListener("abort", () => { this.#isRunning = false; this.#logger.log("Hermes client stopped"); @@ -550,11 +292,7 @@ export class HermesClient { "Starting automatic price consumption", ); - if (!this.#cosmClient) { - await this.initialize(); - } - - const smartContractConfig = await this.queryConfig(); + const smartContractConfig = await this.#signingClient.queryConfig(); const priceStream = this.#config.priceProducerFactory({ priceFeedId: smartContractConfig.price_feed_id, signal, @@ -599,6 +337,7 @@ export class HermesClient { this.#logger.error(safeMessage); throw new Error(safeMessage); } finally { + await this.#signingClient.disconnect(); this.#isRunning = false; } } @@ -616,11 +355,14 @@ export class HermesClient { }> { // SEC-08: Only return non-sensitive operational status fields. // Never include mnemonic, gasPrice, rpcEndpoint, or full config. - const smartContractConfig = await this.queryConfig(); + const [smartContractConfig, account] = await Promise.all([ + this.#signingClient.queryConfig(), + this.#signingClient.getAccount(), + ]); return { isRunning: this.#isRunning, - address: this.#senderAddress, + address: account.address, priceFeedId: smartContractConfig.price_feed_id, contractAddress: this.#config.contractAddress, lastPriceUpdateReceivedAt: this.#lastPriceReceivedAt, @@ -629,10 +371,6 @@ export class HermesClient { } } -export type { - ConfigResponse, DataSourceResponse, OracleParamsResponse, PriceFeedIdResponse, PriceFeedResponse, PriceResponse, RefreshOracleParamsMsg, TransferAdminMsg, UpdateFeeMsg, -}; - export type ErrorCode = "insufficient_balance" | "timeout" | "connection_issue" | "unknown"; export function classifyError(error: unknown): ErrorCode { diff --git a/src/types.ts b/src/types.ts index 14cd06f..e8ec9a0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,3 @@ -import type { GasPrice } from "@cosmjs/stargate"; - // Pyth price data from Hermes API export interface PythPriceData { id: string; @@ -48,11 +46,7 @@ export interface PriceUpdater { } export interface PriceUpdateOptions { - senderAddress: string; - contractAddress: string; - denom: string; updateFee: string; - gasPrice: GasPrice; } export interface UpdatePriceFeedMsg {