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: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ akash query wasm contract-state smart $HC_CONTRACT_ADDRESS '{"get_config":{}}'
| `HC_CONTRACT_ADDRESS` | Yes | - | Oracle contract address |
| `HC_WALLET_SECRET` | Yes | - | Either `privateKey:<private key in hex format>` or `mnemonic:<12/24 words>` |
| `HC_HERMES_ENDPOINT` | No | `https://hermes.pyth.network` | Pyth Hermes API |
| `HC_PRICE_DEVIATION_TOLERANCE` | No | 0 | absolute or percentage value for price deviations which should be ignored (e.g., `100` or `10%`) |
| `HC_PRICE_DEVIATION_TOLERANCE` | No | - | absolute or percentage value for price deviations which should be ignored (e.g., `100` or `10%`). When unset, every price with a newer publish time is submitted |
| `HC_PRICE_FETCHING_METHOD` | No | polling | `polling` or `sse` |
| `HC_PRICE_UPDATE_TX_METHOD` | No | ordered | `ordered` or `unordered` |
| `HC_UPDATE_INTERVAL_MS` | No | `5000` | Update interval (default 5 sec) |
Expand Down
48 changes: 46 additions & 2 deletions src/services/hermes-client/hermes-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,18 @@ describe(HermesClient.name, () => {
);
});

it("skips update when the price is unchanged and the tolerance is absolute 0", async () => {
const { client, contractClient } = setup({
priceDeviationTolerance: { type: "absolute", value: 0 },
priceFeed: buildPriceFeed("10000", -2, 2000),
});
contractClient.queryCurrentPrice.mockResolvedValue(buildCurrentPrice("10000", -2, 1000));

await client.updatePrice();

expect(contractClient.updatePrice).not.toHaveBeenCalled();
});

it("skips update when percentage deviation is within tolerance", async () => {
const { client, contractClient, logger } = setup({
priceDeviationTolerance: { type: "percentage", value: 1 },
Expand Down Expand Up @@ -294,7 +306,7 @@ describe(HermesClient.name, () => {
);
});

it("updates on any price difference with default tolerance (absolute 0)", async () => {
it("updates on any price difference when no tolerance is configured", async () => {
const { client, contractClient } = setup({
priceFeed: buildPriceFeed("10001", -2, 2000),
});
Expand All @@ -305,6 +317,29 @@ describe(HermesClient.name, () => {
expect(contractClient.updatePrice).toHaveBeenCalledTimes(1);
});

it("updates on an unchanged price when no tolerance is configured", async () => {
const { client, contractClient } = setup({
priceFeed: buildPriceFeed("10000", -2, 2000),
});
contractClient.queryCurrentPrice.mockResolvedValue(buildCurrentPrice("10000", -2, 1000));

await client.updatePrice();

expect(contractClient.updatePrice).toHaveBeenCalledTimes(1);
});

it("does not run a deviation check when no tolerance is configured", async () => {
const { client, logger } = setup({
priceFeed: buildPriceFeed("10000", -2, 2000),
});

await client.updatePrice();

expect(logger.log).not.toHaveBeenCalledWith(
expect.stringContaining("Checking if price deviation is acceptable"),
);
});

it("handles different exponents between new and current price", async () => {
const { client, contractClient } = setup({
priceDeviationTolerance: { type: "absolute", value: 1.0 },
Expand All @@ -317,6 +352,15 @@ describe(HermesClient.name, () => {
expect(contractClient.updatePrice).toHaveBeenCalledTimes(1);
});

it("fails the update on an unknown tolerance type", async () => {
const { client } = setup({
priceDeviationTolerance: { type: "relative", value: 1 } as unknown as HermesConfig["priceDeviationTolerance"],
priceFeed: buildPriceFeed("10000", -2, 2000),
});

await expect(client.updatePrice()).rejects.toThrow("Failed to update price: Unknown price deviation tolerance type: relative");
});

it("handles zero current price when calculating percentage deviation", async () => {
const { client, contractClient } = setup({
priceDeviationTolerance: { type: "percentage", value: 10 },
Expand Down Expand Up @@ -695,7 +739,7 @@ function setup(input?: Partial<HermesConfig> & {
gasPrice: input?.gasPrice ?? "0.025uakt",
logger,
unsafeAllowInsecureEndpoints: input?.unsafeAllowInsecureEndpoints,
priceDeviationTolerance: input?.priceDeviationTolerance ?? { type: "absolute", value: 0 },
priceDeviationTolerance: input?.priceDeviationTolerance,
priceProducerFactory: (input?.priceProducerFactory ?? priceProducerFactory) as PriceProducerFactory,
smartContractConfigCacheTTLMs: input?.smartContractConfigCacheTTLMs ?? 60_000,
unorderedTxTtlMs: input?.unorderedTxTtlMs ?? 180_000,
Expand Down
25 changes: 13 additions & 12 deletions src/services/hermes-client/hermes-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ export interface HermesConfig {
rpcEndpoint: string;
contractAddress: string;
walletSecret: SigningClientServiceConfig["walletSecret"];

priceUpdateTxMethod: "ordered" | "unordered";
denom: string;
gasPrice: string;
Expand All @@ -40,6 +39,8 @@ export interface HermesConfig {
unorderedTxTtlMs: number;
/**
* Optional threshold for skipping updates when the price change is below a tolerance.
* When omitted, no deviation filtering is applied and every price with a newer
* publish time is submitted, even if the value is unchanged.
*
* - 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).
Expand Down Expand Up @@ -78,11 +79,9 @@ export interface HermesConfig {
*/
export type ContractClient = Pick<ContractClientService, "getAccount" | "queryConfig" | "queryCurrentPrice" | "updatePrice" | "disconnect">;

const DEFAULT_PRICE_DEVIATION_TOLERANCE: Required<HermesConfig>["priceDeviationTolerance"] = { type: "absolute", value: 0 };

export class HermesClient {
readonly #signingClient: ContractClient;
readonly #config: Required<Omit<HermesConfig, "fetch" | "logger" | "gasPrice" | "unsafeAllowInsecureEndpoints" | "priceUpdateTxMethod" | "smartContractConfigCacheTTLMs" | "gasMultiplier" | "unorderedTxTtlMs" | "contractClientFactory">>;
readonly #config: HermesConfig & Required<Pick<HermesConfig, "insufficientBalanceRetryDelayMs">>;
#isRunning = false;
#insufficientBalanceCooldownUntil: number | null = null;
#lastPriceReceivedAt?: string;
Expand All @@ -98,7 +97,6 @@ export class HermesClient {

this.#config = {
...config,
priceDeviationTolerance: config.priceDeviationTolerance ?? DEFAULT_PRICE_DEVIATION_TOLERANCE,
insufficientBalanceRetryDelayMs: config.insufficientBalanceRetryDelayMs ?? 60_000,
};
this.#logger = config.logger ?? console;
Expand Down Expand Up @@ -238,27 +236,30 @@ export class HermesClient {
}

#isPriceDeviationAcceptable(newPrice: PythPriceData, currentPrice: PriceResponse): boolean {
const priceDeviationTolerance = this.#config.priceDeviationTolerance;
if (!priceDeviationTolerance) return false;

const newPriceValue = parseFloat(newPrice.price.price) * Math.pow(10, newPrice.price.expo);
const currentPriceValue = parseFloat(currentPrice.price) * Math.pow(10, currentPrice.expo);
let isAcceptable = false;

this.#logger.log(`Checking if price deviation is acceptable: new=${newPriceValue}, current=${currentPriceValue}`);

if (this.#config.priceDeviationTolerance.type === "absolute") {
if (priceDeviationTolerance.type === "absolute") {
const deviation = Math.abs(newPriceValue - currentPriceValue);
isAcceptable = deviation <= this.#config.priceDeviationTolerance.value;
isAcceptable = deviation <= priceDeviationTolerance.value;

if (isAcceptable) {
this.#logger.log(`Price deviation ${deviation} within absolute tolerance ${this.#config.priceDeviationTolerance.value}, skipping update`);
this.#logger.log(`Price deviation ${deviation} within absolute tolerance ${priceDeviationTolerance.value}, skipping update`);
}
} else if (this.#config.priceDeviationTolerance.type === "percentage") {
} else if (priceDeviationTolerance.type === "percentage") {
const deviationPercent = currentPriceValue === 0 ? Number.MAX_SAFE_INTEGER : Math.abs(newPriceValue - currentPriceValue) / currentPriceValue;
isAcceptable = deviationPercent <= this.#config.priceDeviationTolerance.value / 100;
isAcceptable = deviationPercent <= priceDeviationTolerance.value / 100;
if (isAcceptable) {
this.#logger.log(`Price deviation ${(deviationPercent * 100).toFixed(2)}% within percentage tolerance ${(this.#config.priceDeviationTolerance.value).toFixed(2)}%, skipping update`);
this.#logger.log(`Price deviation ${(deviationPercent * 100).toFixed(2)}% within percentage tolerance ${(priceDeviationTolerance.value).toFixed(2)}%, skipping update`);
}
} else {
throw new Error(`Unknown price deviation tolerance type: ${this.#config.priceDeviationTolerance.type}`);
throw new Error(`Unknown price deviation tolerance type: ${priceDeviationTolerance.type}`);
}

return isAcceptable;
Expand Down