|
1 | 1 | import { SigningCosmWasmClient } from "@cosmjs/cosmwasm-stargate"; |
2 | 2 | import { afterEach, describe, expect, it, vi } from "vitest"; |
3 | 3 | 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"; |
5 | 6 | import type { PriceUpdate, PriceProducerFactory, PriceProducerFactoryOptions } from "./types.ts"; |
6 | 7 |
|
7 | 8 | // ============================================================ |
@@ -415,6 +416,55 @@ describe(HermesClient.name, () => { |
415 | 416 | }); |
416 | 417 | }); |
417 | 418 |
|
| 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 | + |
418 | 468 | function mockForUpdate(stargateClient: ReturnType<typeof setup>["stargateClient"], currentPrice: { price: string; expo: number; publish_time: number }) { |
419 | 469 | stargateClient.queryContractSmart |
420 | 470 | .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, () => { |
853 | 903 |
|
854 | 904 | expect(stargateClient.execute).toHaveBeenCalledTimes(1); |
855 | 905 | }); |
| 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 | + }); |
856 | 1009 | }); |
857 | 1010 | }); |
858 | 1011 |
|
@@ -887,6 +1040,7 @@ function setup(input?: Partial<HermesConfig> & { |
887 | 1040 | priceDeviationTolerance: input?.priceDeviationTolerance ?? { type: "absolute", value: 0 }, |
888 | 1041 | priceProducerFactory: (input?.priceProducerFactory ?? priceProducerFactory) as PriceProducerFactory, |
889 | 1042 | smartContractConfigCacheTTLMs: input?.smartContractConfigCacheTTLMs ?? 60_000, |
| 1043 | + insufficientBalanceRetryDelayMs: input?.insufficientBalanceRetryDelayMs, |
890 | 1044 | }); |
891 | 1045 |
|
892 | 1046 | return { client, priceUpdate, priceProducerFactory, logger, stargateClient }; |
|
0 commit comments