Skip to content
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { describe, expect, it, vi } from "vitest";
import { mockDeep } from "vitest-mock-extended";
import { mock, mockDeep } from "vitest-mock-extended";

import type { ChainSDK } from "@src/chain/providers/chain-sdk.provider";
import type { LoggerService } from "@src/core/providers/logging.provider";
import type { DayRepository } from "@src/gpu/repositories/day.repository";
import { DenomExchangeService } from "./denom-exchange.service";

describe(DenomExchangeService.name, () => {
Expand Down Expand Up @@ -53,26 +55,93 @@ describe(DenomExchangeService.name, () => {
expect(result.priceChange24h).toBe(0);
expect(result.priceChangePercentage24).toBe(0);
});

it("falls back to DB price when oracle reports unhealthy", async () => {
const { service, dayRepository, logger } = setup({ isHealthy: false, latestAktPrice: 1.23 });

const result = await service.getExchangeRateToUSD("akt");

expect(dayRepository.getLatestAktPrice).toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ event: "ORACLE_PRICE_UNHEALTHY" }));
expect(result).toEqual({
price: 1.23,
volume: 0,
marketCap: 0,
marketCapRank: 0,
priceChange24h: 0,
priceChangePercentage24: 0
});
});

it("falls back to DB price when oracle RPC fails", async () => {
const { service, dayRepository, logger } = setup({ oracleThrows: true, latestAktPrice: 0.99 });

const result = await service.getExchangeRateToUSD("akt");

expect(dayRepository.getLatestAktPrice).toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ event: "ORACLE_RPC_FAILED" }));
expect(result).toEqual({
price: 0.99,
volume: 0,
marketCap: 0,
marketCapRank: 0,
priceChange24h: 0,
priceChangePercentage24: 0
});
});

it("returns zero price when oracle fails and DB has no price", async () => {
const { service, logger } = setup({ oracleThrows: true, latestAktPrice: null });

const result = await service.getExchangeRateToUSD("akt");

expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ event: "ORACLE_RPC_FAILED" }));
expect(result).toEqual({
price: 0,
volume: 0,
marketCap: 0,
marketCapRank: 0,
priceChange24h: 0,
priceChangePercentage24: 0
});
});
});

function setup(input: { currentHeight?: bigint; historicalPrice?: string; emptyHistoricalPrices?: boolean }) {
function setup(input: {
currentHeight?: bigint;
historicalPrice?: string;
emptyHistoricalPrices?: boolean;
isHealthy?: boolean;
oracleThrows?: boolean;
latestAktPrice?: number | null;
}) {
const getLatestBlock = vi.fn().mockResolvedValue({
block: { header: { height: { toBigInt: () => input.currentHeight ?? 1000000n } } }
});
const getAggregatedPrice = vi.fn().mockResolvedValue({
aggregatedPrice: { medianPrice: "0.56" }
aggregatedPrice: { medianPrice: "0.56" },
priceHealth: { isHealthy: input.isHealthy ?? true }
});
const getPrices = vi.fn().mockResolvedValue({
prices: input.emptyHistoricalPrices ? [] : [{ state: { price: input.historicalPrice ?? "0.5" } }]
});

if (input.oracleThrows) {
getLatestBlock.mockRejectedValue(new Error("RPC connection refused"));
}

const chainSdk = mockDeep<ChainSDK>();
chainSdk.cosmos.base.tendermint.v1beta1.getLatestBlock.mockImplementation(getLatestBlock);
chainSdk.akash.oracle.v1.getAggregatedPrice.mockImplementation(getAggregatedPrice);
chainSdk.akash.oracle.v1.getPrices.mockImplementation(getPrices);

const service = new DenomExchangeService(chainSdk);
const dayRepository = mock<DayRepository>();
dayRepository.getLatestAktPrice.mockResolvedValue("latestAktPrice" in input ? input.latestAktPrice! : 1.23);

const logger = mock<LoggerService>();

const service = new DenomExchangeService(chainSdk, dayRepository, logger);

return { service, getLatestBlock, getAggregatedPrice, getPrices };
return { service, getLatestBlock, getAggregatedPrice, getPrices, dayRepository, logger };
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,20 @@ import { inject, singleton } from "tsyringe";

import { memoizeAsync } from "@src/caching/helpers";
import { CHAIN_SDK, type ChainSDK } from "@src/chain/providers/chain-sdk.provider";
import { LoggerService } from "@src/core/providers/logging.provider";
import { DayRepository } from "@src/gpu/repositories/day.repository";
import { averageBlockTime } from "@src/utils/constants";

@singleton()
export class DenomExchangeService {
readonly #chainSdk: ChainSDK;
readonly #dayRepository: DayRepository;
readonly #logger: LoggerService;

constructor(@inject(CHAIN_SDK) chainSdk: ChainSDK) {
constructor(@inject(CHAIN_SDK) chainSdk: ChainSDK, dayRepository: DayRepository, logger: LoggerService) {
this.#chainSdk = chainSdk;
this.#dayRepository = dayRepository;
this.#logger = logger;
}

getExchangeRateToUSD = memoizeAsync(
Expand All @@ -20,29 +26,53 @@ export class DenomExchangeService {
};
const mappedDenom = legacyToNewMapping[denom] ?? denom;

const latestBlock = await this.#chainSdk.cosmos.base.tendermint.v1beta1.getLatestBlock();
const currentHeight = latestBlock.block?.header?.height?.toBigInt() ?? 0n;
const blocksPerDay = (24n * 60n * 60n) / BigInt(Math.ceil(averageBlockTime));
const blockHeight24hAgo = currentHeight > blocksPerDay ? currentHeight - blocksPerDay : 1n;
const [currentRate, rate24hAgo] = await Promise.all([
this.#chainSdk.akash.oracle.v1.getAggregatedPrice({ denom: mappedDenom }),
this.#chainSdk.akash.oracle.v1.getPrices({
filters: { assetDenom: mappedDenom, baseDenom: "usd", height: blockHeight24hAgo },
pagination: { limit: 1 }
})
]);
const price = parseFloat(currentRate.aggregatedPrice?.medianPrice ?? "0");
const price24hAgo = rate24hAgo.prices[0]?.state?.price ? parseFloat(rate24hAgo.prices[0].state.price) : price;

return {
price,
volume: 0,
marketCap: 0,
marketCapRank: 0,
priceChange24h: price - price24hAgo,
priceChangePercentage24: price24hAgo ? ((price - price24hAgo) / price24hAgo) * 100 : 0
};
try {
const latestBlock = await this.#chainSdk.cosmos.base.tendermint.v1beta1.getLatestBlock();
const currentHeight = latestBlock.block?.header?.height?.toBigInt() ?? 0n;
const blocksPerDay = (24n * 60n * 60n) / BigInt(Math.ceil(averageBlockTime));
const blockHeight24hAgo = currentHeight > blocksPerDay ? currentHeight - blocksPerDay : 1n;
const [oracleRate, rate24hAgo] = await Promise.all([
this.#chainSdk.akash.oracle.v1.getAggregatedPrice({ denom: mappedDenom }),
this.#chainSdk.akash.oracle.v1.getPrices({
filters: { assetDenom: mappedDenom, baseDenom: "usd", height: blockHeight24hAgo },
pagination: { limit: 1 }
})
]);

if (!oracleRate.priceHealth?.isHealthy) {
this.#logger.warn({ event: "ORACLE_PRICE_UNHEALTHY", denom: mappedDenom });
return await this.#getFallbackExchangeRateToUSD();
}

const price = parseFloat(oracleRate.aggregatedPrice?.medianPrice ?? "0");
const price24hAgo = rate24hAgo.prices[0]?.state?.price ? parseFloat(rate24hAgo.prices[0].state.price) : price;

return {
price,
volume: 0,
marketCap: 0,
marketCapRank: 0,
priceChange24h: price - price24hAgo,
priceChangePercentage24: price24hAgo ? ((price - price24hAgo) / price24hAgo) * 100 : 0
};
} catch (error) {
this.#logger.warn({ event: "ORACLE_RPC_FAILED", denom: mappedDenom, error });
return await this.#getFallbackExchangeRateToUSD();
}
},
{ cacheItemLimit: 10, ttl: minutesToMilliseconds(10) }
);

async #getFallbackExchangeRateToUSD() {
const aktPrice = await this.#dayRepository.getLatestAktPrice();

return {
price: aktPrice ?? 0,
volume: 0,
marketCap: 0,
marketCapRank: 0,
priceChange24h: 0,
priceChangePercentage24: 0
};
}
}
11 changes: 11 additions & 0 deletions apps/api/src/gpu/repositories/day.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,15 @@ export class DayRepository {
async getDaysAfter(date: Date): Promise<Day[]> {
return await Day.findAll({ where: { date: { [Op.gte]: date } }, raw: true });
}

async getLatestAktPrice(): Promise<number | null> {
const day = await Day.findOne({
where: { aktPrice: { [Op.ne]: null } },
order: [["date", "DESC"]],
attributes: ["aktPrice"],
raw: true
});

return day?.aktPrice ?? null;
}
}
4 changes: 2 additions & 2 deletions apps/api/test/functional/market-data.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,13 @@ describe("Market Data", () => {
.persist()
.get("/akash/oracle/v1/aggregated_price/akt")
.query(true)
.reply(200, { aggregated_price: { median_price: "1.5" } });
.reply(200, { aggregated_price: { median_price: "1.5" }, price_health: { is_healthy: true } });

nock(restApiNodeUrl)
.persist()
.get("/akash/oracle/v1/aggregated_price/usdc")
.query(true)
.reply(200, { aggregated_price: { median_price: "1.0" } });
.reply(200, { aggregated_price: { median_price: "1.0" }, price_health: { is_healthy: true } });

nock(restApiNodeUrl)
.persist()
Expand Down
Loading