Skip to content

Commit 8bb2645

Browse files
authored
feat: adds more metrics (#14)
1 parent 63c3458 commit 8bb2645

11 files changed

Lines changed: 274 additions & 48 deletions

File tree

package.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@
22
"name": "akash-hermes-client",
33
"version": "1.0.0",
44
"description": "Hermes client for updating Akash oracle with Pyth price data",
5-
"main": "dist/hermes-client.js",
6-
"types": "dist/hermes-client.d.ts",
75
"type": "module",
86
"bin": {
97
"hermes-cli": "dist/cli.js"

src/cli-commands/command-config.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,28 @@ describe("parseConfig", () => {
187187
expect((result as Extract<typeof result, { ok: false }>).error).toContain("PRICE_DEVIATION_TOLERANCE");
188188
});
189189

190+
describe("INSUFFICIENT_BALANCE_RETRY_DELAY_MS", () => {
191+
it("defaults to 60000 when not provided", () => {
192+
const result = parseConfig(validEnv());
193+
194+
expect(result.ok).toBe(true);
195+
expect((result as Extract<typeof result, { ok: true }>).value.insufficientBalanceRetryDelayMs).toBe(60000);
196+
});
197+
198+
it("parses custom value", () => {
199+
const result = parseConfig(validEnv({ INSUFFICIENT_BALANCE_RETRY_DELAY_MS: "120000" }));
200+
201+
expect(result.ok).toBe(true);
202+
expect((result as Extract<typeof result, { ok: true }>).value.insufficientBalanceRetryDelayMs).toBe(120000);
203+
});
204+
205+
it("rejects negative values", () => {
206+
const result = parseConfig(validEnv({ INSUFFICIENT_BALANCE_RETRY_DELAY_MS: "-1" }));
207+
208+
expect(result.ok).toBe(false);
209+
});
210+
});
211+
190212
function validEnv(overrides: Record<string, string | undefined> = {}) {
191213
return {
192214
CONTRACT_ADDRESS: "akash1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5lzv7xu",

src/cli-commands/command-config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export interface CommandConfig extends HermesConfig {
99
createHermesClient: (config: HermesConfig) => Promise<HermesClient>;
1010
signal: AbortSignal;
1111
healthcheckPort: number;
12+
insufficientBalanceRetryDelayMs: number;
1213
rawConfig: z.infer<typeof configSchema>;
1314
}
1415

@@ -49,6 +50,7 @@ const configSchema = z.object({
4950
DENOM: z.string().default("uakt"),
5051
NODE_ENV: z.enum(["development", "production"]).optional(),
5152
SMART_CONTRACT_CONFIG_CACHE_TTL_MS: z.coerce.number().int().min(1000).positive().default(60 * 60 * 1000),
53+
INSUFFICIENT_BALANCE_RETRY_DELAY_MS: z.coerce.number().int().nonnegative().default(60_000),
5254
});
5355

5456
type ParsedConfig = Omit<CommandConfig, "signal" | "logger">;
@@ -72,6 +74,7 @@ export function parseConfig(config: Record<string, string | undefined>): ParseCo
7274
denom: result.data.DENOM,
7375
priceDeviationTolerance: result.data.PRICE_DEVIATION_TOLERANCE,
7476
smartContractConfigCacheTTLMs: result.data.SMART_CONTRACT_CONFIG_CACHE_TTL_MS,
77+
insufficientBalanceRetryDelayMs: result.data.INSUFFICIENT_BALANCE_RETRY_DELAY_MS,
7578
priceProducerFactory(options: PriceProducerFactoryOptions) {
7679
if (result.data.PRICE_FETCHING_METHOD === "sse") {
7780
return priceSSEStream({

src/cli-commands/update-command.test.ts

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,9 @@
11
import { describe, expect, it, vi } from "vitest";
22
import { mock } from "vitest-mock-extended";
33
import type { HermesClient } from "../hermes-client.ts";
4-
import type { PriceUpdate } from "../types.ts";
54
import type { CommandConfig } from "./command-config.ts";
65
import { updateCommand } from "./update-command.ts";
76

8-
const fakePriceUpdate: PriceUpdate = {
9-
priceData: {
10-
id: "test-feed-id",
11-
price: { price: "100", conf: "1", expo: -8, publish_time: 1000 },
12-
ema_price: { price: "100", conf: "1", expo: -8, publish_time: 1000 },
13-
},
14-
vaa: "dGVzdC12YWE=",
15-
};
16-
17-
async function* fakePriceProducer(): AsyncGenerator<PriceUpdate, void, unknown> {
18-
yield fakePriceUpdate;
19-
}
20-
217
function setup() {
228
const client = mock<HermesClient>();
239
client.queryConfig.mockResolvedValue({
@@ -39,7 +25,7 @@ function setup() {
3925
healthcheckPort: 3000,
4026
rawConfig: {} as CommandConfig["rawConfig"],
4127
smartContractConfigCacheTTLMs: 60000,
42-
priceProducerFactory: vi.fn(() => fakePriceProducer()),
28+
priceProducerFactory: vi.fn(),
4329
createHermesClient: vi.fn(() => Promise.resolve(client)),
4430
};
4531
return { config, client, logger };
@@ -51,15 +37,14 @@ describe("updateCommand", () => {
5137
await updateCommand(config);
5238

5339
expect(logger.log).toHaveBeenCalledWith("Updating oracle price...\n");
54-
expect(logger.log).toHaveBeenCalledWith("\nUpdate completed successfully!");
5540
});
5641

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

6146
expect(config.createHermesClient).toHaveBeenCalledWith(config);
62-
expect(client.updatePrice).toHaveBeenCalledWith(fakePriceUpdate);
47+
expect(client.updatePrice).toHaveBeenCalledWith({ signal: config.signal });
6348
});
6449

6550
it("propagates errors from updatePrice", async () => {

src/cli-commands/update-command.ts

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,5 @@ import type { CommandConfig } from "./command-config.ts";
33
export async function updateCommand(config: CommandConfig): Promise<void> {
44
config.logger?.log("Updating oracle price...\n");
55
const client = await config.createHermesClient(config);
6-
const smartCotractConfig = await client.queryConfig();
7-
const priceStream = config.priceProducerFactory({
8-
priceFeedId: smartCotractConfig.price_feed_id,
9-
logger: config.logger,
10-
signal: config.signal,
11-
});
12-
const priceUpdate = await priceStream.next();
13-
if (priceUpdate.value) {
14-
await client.updatePrice(priceUpdate.value);
15-
config.logger?.log("\nUpdate completed successfully!");
16-
} else {
17-
config.logger?.log("\nUpdate skipped because no new price was available.");
18-
}
6+
await client.updatePrice({ signal: config.signal });
197
}

src/hermes-client.test.ts

Lines changed: 155 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { SigningCosmWasmClient } from "@cosmjs/cosmwasm-stargate";
22
import { afterEach, describe, expect, it, vi } from "vitest";
33
import { mock } from "vitest-mock-extended";
4-
import { HermesClient, HermesConfig } from "./hermes-client";
4+
import { HermesClient, HermesConfig, classifyError } from "./hermes-client";
5+
import { blockchainPriceStaleness, priceUpdateCounter } from "./metrics.ts";
56
import type { PriceUpdate, PriceProducerFactory, PriceProducerFactoryOptions } from "./types.ts";
67

78
// ============================================================
@@ -415,6 +416,55 @@ describe(HermesClient.name, () => {
415416
});
416417
});
417418

419+
it("records price staleness on successful update", async () => {
420+
const stalenessSpy = vi.spyOn(blockchainPriceStaleness, "record");
421+
const { client, priceUpdate, stargateClient } = setup({
422+
priceFeed: buildPriceFeed("10000", -2, 2000),
423+
});
424+
mockForUpdate(stargateClient, { price: "9000", expo: -2, publish_time: 1000 });
425+
426+
await client.initialize();
427+
await client.updatePrice(priceUpdate);
428+
429+
expect(stalenessSpy).toHaveBeenCalledWith(1000);
430+
stalenessSpy.mockRestore();
431+
});
432+
433+
it("records price staleness on skipped update", async () => {
434+
const stalenessSpy = vi.spyOn(blockchainPriceStaleness, "record");
435+
const { client, priceUpdate, stargateClient } = setup({
436+
priceFeed: buildPriceFeed("10000", -2, 2000),
437+
});
438+
mockForSkip(stargateClient, { price: "10000", expo: -2, publish_time: 2000 });
439+
440+
await client.initialize();
441+
await client.updatePrice(priceUpdate);
442+
443+
expect(stalenessSpy).toHaveBeenCalledWith(0);
444+
stalenessSpy.mockRestore();
445+
});
446+
447+
it("records error_code attribute and staleness on failure", async () => {
448+
const counterSpy = vi.spyOn(priceUpdateCounter, "add");
449+
const stalenessSpy = vi.spyOn(blockchainPriceStaleness, "record");
450+
const { client, stargateClient } = setup({
451+
priceFeed: buildPriceFeed("10000", -2, 2000),
452+
});
453+
454+
stargateClient.queryContractSmart
455+
.mockResolvedValueOnce({ price_feed_id: "test-feed-id", update_fee: "1", wormhole_contract: "akash1wormhole", admin: "akash1admin", default_denom: "uakt", default_base_denom: "akt", data_sources: [] })
456+
.mockResolvedValueOnce({ price: "9000", conf: "10", expo: -2, publish_time: 1000 });
457+
stargateClient.execute.mockRejectedValueOnce(new Error("insufficient funds"));
458+
459+
await client.initialize();
460+
await client.updatePrice(buildPriceFeed("10000", -2, 2000)).catch(() => {});
461+
462+
expect(counterSpy).toHaveBeenCalledWith(1, { result: "failure", error_code: "insufficient_balance" });
463+
expect(stalenessSpy).toHaveBeenCalledWith(1000);
464+
counterSpy.mockRestore();
465+
stalenessSpy.mockRestore();
466+
});
467+
418468
function mockForUpdate(stargateClient: ReturnType<typeof setup>["stargateClient"], currentPrice: { price: string; expo: number; publish_time: number }) {
419469
stargateClient.queryContractSmart
420470
.mockResolvedValueOnce({ price_feed_id: "test-feed-id", update_fee: "1", wormhole_contract: "akash1wormhole", admin: "akash1admin", default_denom: "uakt", default_base_denom: "akt", data_sources: [] })
@@ -853,6 +903,109 @@ describe(HermesClient.name, () => {
853903

854904
expect(stargateClient.execute).toHaveBeenCalledTimes(1);
855905
});
906+
907+
it("enters cooldown on insufficient balance error and retries after delay", async () => {
908+
vi.useFakeTimers();
909+
// Use resolvers to control when each price update is delivered
910+
const { promise: secondUpdateReady, resolve: releaseSecondUpdate } = Promise.withResolvers<void>();
911+
const factory = vi.fn(async function* ({ signal }: PriceProducerFactoryOptions) {
912+
// First update: will trigger insufficient funds
913+
yield buildPriceFeed("10000", -2, 2000);
914+
// Wait until test signals to release the second update (after cooldown)
915+
await secondUpdateReady;
916+
// Second update: will succeed after cooldown
917+
yield buildPriceFeed("10200", -2, 4000);
918+
if (signal && !signal.aborted) {
919+
await new Promise<void>(resolve => {
920+
signal.addEventListener("abort", () => resolve(), { once: true });
921+
});
922+
}
923+
});
924+
const { client, stargateClient, logger } = setup({
925+
priceProducerFactory: factory as unknown as PriceProducerFactory,
926+
insufficientBalanceRetryDelayMs: 5000,
927+
});
928+
929+
// queryConfig (from start())
930+
stargateClient.queryContractSmart
931+
.mockResolvedValueOnce({ price_feed_id: "test-feed-id", update_fee: "1", wormhole_contract: "akash1wormhole", admin: "akash1admin", default_denom: "uakt", default_base_denom: "akt", data_sources: [] });
932+
// First update attempt: queryCurrentPrice then execute fails with insufficient funds
933+
stargateClient.queryContractSmart
934+
.mockResolvedValueOnce({ price: "9000", conf: "10", expo: -2, publish_time: 1000 });
935+
stargateClient.execute.mockRejectedValueOnce(new Error("insufficient funds"));
936+
937+
// Second update (after cooldown): queryCurrentPrice then execute succeeds
938+
stargateClient.queryContractSmart
939+
.mockResolvedValueOnce({ price: "9000", conf: "10", expo: -2, publish_time: 1000 });
940+
stargateClient.execute.mockResolvedValueOnce({
941+
transactionHash: "TX_RECOVERY",
942+
gasUsed: 500000n,
943+
gasWanted: 600000n,
944+
height: 100,
945+
events: [],
946+
logs: [],
947+
});
948+
949+
const ac = new AbortController();
950+
const startPromise = client.start({ signal: ac.signal });
951+
952+
// Wait for the cooldown warning to appear
953+
await vi.waitFor(() => {
954+
expect(logger.warn).toHaveBeenCalledWith(
955+
expect.stringContaining("insufficient balance"),
956+
);
957+
});
958+
959+
// Advance time past the cooldown and release the next update
960+
await vi.advanceTimersByTimeAsync(5000);
961+
releaseSecondUpdate();
962+
963+
// Wait for the recovery attempt to succeed
964+
await vi.waitFor(() => {
965+
expect(stargateClient.execute).toHaveBeenCalledTimes(2);
966+
});
967+
968+
ac.abort();
969+
await startPromise;
970+
});
971+
});
972+
973+
describe("classifyError()", () => {
974+
it('returns "insufficient_balance" for insufficient funds error', () => {
975+
expect(classifyError(new Error("insufficient funds: 100uakt < 1000uakt"))).toBe("insufficient_balance");
976+
});
977+
978+
it('returns "insufficient_balance" for insufficient fee error', () => {
979+
expect(classifyError(new Error("insufficient fee"))).toBe("insufficient_balance");
980+
});
981+
982+
it('returns "timeout" for timeout error', () => {
983+
expect(classifyError(new Error("request timeout"))).toBe("timeout");
984+
});
985+
986+
it('returns "timeout" for ETIMEDOUT error', () => {
987+
expect(classifyError(new Error("connect ETIMEDOUT 1.2.3.4:443"))).toBe("timeout");
988+
});
989+
990+
it('returns "connection_issue" for ECONNREFUSED error', () => {
991+
expect(classifyError(new Error("connect ECONNREFUSED 127.0.0.1:26657"))).toBe("connection_issue");
992+
});
993+
994+
it('returns "connection_issue" for ECONNRESET error', () => {
995+
expect(classifyError(new Error("read ECONNRESET"))).toBe("connection_issue");
996+
});
997+
998+
it('returns "connection_issue" for ENOTFOUND error', () => {
999+
expect(classifyError(new Error("getaddrinfo ENOTFOUND rpc.example.com"))).toBe("connection_issue");
1000+
});
1001+
1002+
it('returns "unknown" for unrecognized errors', () => {
1003+
expect(classifyError(new Error("something unexpected"))).toBe("unknown");
1004+
});
1005+
1006+
it('returns "unknown" for non-Error values', () => {
1007+
expect(classifyError("string error")).toBe("unknown");
1008+
});
8561009
});
8571010
});
8581011

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

8921046
return { client, priceUpdate, priceProducerFactory, logger, stargateClient };

0 commit comments

Comments
 (0)