Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@
"name": "akash-hermes-client",
"version": "1.0.0",
"description": "Hermes client for updating Akash oracle with Pyth price data",
"main": "dist/hermes-client.js",
"types": "dist/hermes-client.d.ts",
"type": "module",
"bin": {
"hermes-cli": "dist/cli.js"
Expand Down
22 changes: 22 additions & 0 deletions src/cli-commands/command-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,28 @@ describe("parseConfig", () => {
expect((result as Extract<typeof result, { ok: false }>).error).toContain("PRICE_DEVIATION_TOLERANCE");
});

describe("INSUFFICIENT_BALANCE_RETRY_DELAY_MS", () => {
it("defaults to 60000 when not provided", () => {
const result = parseConfig(validEnv());

expect(result.ok).toBe(true);
expect((result as Extract<typeof result, { ok: true }>).value.insufficientBalanceRetryDelayMs).toBe(60000);
});

it("parses custom value", () => {
const result = parseConfig(validEnv({ INSUFFICIENT_BALANCE_RETRY_DELAY_MS: "120000" }));

expect(result.ok).toBe(true);
expect((result as Extract<typeof result, { ok: true }>).value.insufficientBalanceRetryDelayMs).toBe(120000);
});

it("rejects negative values", () => {
const result = parseConfig(validEnv({ INSUFFICIENT_BALANCE_RETRY_DELAY_MS: "-1" }));

expect(result.ok).toBe(false);
});
});

function validEnv(overrides: Record<string, string | undefined> = {}) {
return {
CONTRACT_ADDRESS: "akash1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5lzv7xu",
Expand Down
3 changes: 3 additions & 0 deletions src/cli-commands/command-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface CommandConfig extends HermesConfig {
createHermesClient: (config: HermesConfig) => Promise<HermesClient>;
signal: AbortSignal;
healthcheckPort: number;
insufficientBalanceRetryDelayMs: number;
rawConfig: z.infer<typeof configSchema>;
}

Expand Down Expand Up @@ -49,6 +50,7 @@ const configSchema = z.object({
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),
INSUFFICIENT_BALANCE_RETRY_DELAY_MS: z.coerce.number().int().nonnegative().default(60_000),
});

type ParsedConfig = Omit<CommandConfig, "signal" | "logger">;
Expand All @@ -72,6 +74,7 @@ export function parseConfig(config: Record<string, string | undefined>): ParseCo
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,
priceProducerFactory(options: PriceProducerFactoryOptions) {
if (result.data.PRICE_FETCHING_METHOD === "sse") {
return priceSSEStream({
Expand Down
19 changes: 2 additions & 17 deletions src/cli-commands/update-command.test.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,9 @@
import { describe, expect, it, vi } from "vitest";
import { mock } from "vitest-mock-extended";
import type { HermesClient } from "../hermes-client.ts";
import type { PriceUpdate } from "../types.ts";
import type { CommandConfig } from "./command-config.ts";
import { updateCommand } from "./update-command.ts";

const fakePriceUpdate: PriceUpdate = {
priceData: {
id: "test-feed-id",
price: { price: "100", conf: "1", expo: -8, publish_time: 1000 },
ema_price: { price: "100", conf: "1", expo: -8, publish_time: 1000 },
},
vaa: "dGVzdC12YWE=",
};

async function* fakePriceProducer(): AsyncGenerator<PriceUpdate, void, unknown> {
yield fakePriceUpdate;
}

function setup() {
const client = mock<HermesClient>();
client.queryConfig.mockResolvedValue({
Expand All @@ -39,7 +25,7 @@ function setup() {
healthcheckPort: 3000,
rawConfig: {} as CommandConfig["rawConfig"],
smartContractConfigCacheTTLMs: 60000,
priceProducerFactory: vi.fn(() => fakePriceProducer()),
priceProducerFactory: vi.fn(),
createHermesClient: vi.fn(() => Promise.resolve(client)),
};
return { config, client, logger };
Expand All @@ -51,15 +37,14 @@ describe("updateCommand", () => {
await updateCommand(config);

expect(logger.log).toHaveBeenCalledWith("Updating oracle price...\n");
expect(logger.log).toHaveBeenCalledWith("\nUpdate completed successfully!");
});

it("creates client and calls updatePrice", async () => {
const { config, client } = setup();
await updateCommand(config);

expect(config.createHermesClient).toHaveBeenCalledWith(config);
expect(client.updatePrice).toHaveBeenCalledWith(fakePriceUpdate);
expect(client.updatePrice).toHaveBeenCalledWith({ signal: config.signal });
});

it("propagates errors from updatePrice", async () => {
Expand Down
14 changes: 1 addition & 13 deletions src/cli-commands/update-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,5 @@ import type { CommandConfig } from "./command-config.ts";
export async function updateCommand(config: CommandConfig): Promise<void> {
config.logger?.log("Updating oracle price...\n");
const client = await config.createHermesClient(config);
const smartCotractConfig = await client.queryConfig();
const priceStream = config.priceProducerFactory({
priceFeedId: smartCotractConfig.price_feed_id,
logger: config.logger,
signal: config.signal,
});
const priceUpdate = await priceStream.next();
if (priceUpdate.value) {
await client.updatePrice(priceUpdate.value);
config.logger?.log("\nUpdate completed successfully!");
} else {
config.logger?.log("\nUpdate skipped because no new price was available.");
}
await client.updatePrice({ signal: config.signal });
}
156 changes: 155 additions & 1 deletion src/hermes-client.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { SigningCosmWasmClient } from "@cosmjs/cosmwasm-stargate";
import { afterEach, describe, expect, it, vi } from "vitest";
import { mock } from "vitest-mock-extended";
import { HermesClient, HermesConfig } from "./hermes-client";
import { HermesClient, HermesConfig, classifyError } from "./hermes-client";
import { blockchainPriceStaleness, priceUpdateCounter } from "./metrics.ts";
import type { PriceUpdate, PriceProducerFactory, PriceProducerFactoryOptions } from "./types.ts";

// ============================================================
Expand Down Expand Up @@ -415,6 +416,55 @@ describe(HermesClient.name, () => {
});
});

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<typeof setup>["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: [] })
Expand Down Expand Up @@ -853,6 +903,109 @@ describe(HermesClient.name, () => {

expect(stargateClient.execute).toHaveBeenCalledTimes(1);
});

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<void>();
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<void>(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");
});
});
});

Expand Down Expand Up @@ -887,6 +1040,7 @@ function setup(input?: Partial<HermesConfig> & {
priceDeviationTolerance: input?.priceDeviationTolerance ?? { type: "absolute", value: 0 },
priceProducerFactory: (input?.priceProducerFactory ?? priceProducerFactory) as PriceProducerFactory,
smartContractConfigCacheTTLMs: input?.smartContractConfigCacheTTLMs ?? 60_000,
insufficientBalanceRetryDelayMs: input?.insufficientBalanceRetryDelayMs,
});

return { client, priceUpdate, priceProducerFactory, logger, stargateClient };
Expand Down
Loading
Loading