Skip to content
Open
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
47 changes: 47 additions & 0 deletions src/shared/models/asset/__tests__/asset.entity.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { createCustomAsset } from '../__mocks__/asset.entity.mock';
import { Asset } from '../asset.entity';

describe('Asset', () => {
describe('#isSanePrice', () => {
it('accepts regular prices', () => {
expect(Asset.isSanePrice(0.0061)).toBe(true);
expect(Asset.isSanePrice(1)).toBe(true);
expect(Asset.isSanePrice(100000)).toBe(true);
});

it('rejects unset, zero, non-finite and degenerate prices', () => {
expect(Asset.isSanePrice(undefined)).toBe(false);
expect(Asset.isSanePrice(0)).toBe(false);
expect(Asset.isSanePrice(NaN)).toBe(false);
expect(Asset.isSanePrice(Infinity)).toBe(false);
expect(Asset.isSanePrice(4.2421310463457016e-60)).toBe(false);
expect(Asset.isSanePrice(1e60)).toBe(false);
});
});

describe('#minimalPriceReferenceAmount', () => {
it('returns the inverse of a sane approxPriceChf', () => {
const asset = createCustomAsset({ approxPriceChf: 0.5 });

expect(asset.minimalPriceReferenceAmount).toBe(2);
});

it('falls back to 1 when approxPriceChf is unset', () => {
const asset = createCustomAsset({ approxPriceChf: undefined });

expect(asset.minimalPriceReferenceAmount).toBe(1);
});

it('falls back to 1 when approxPriceChf is 0', () => {
const asset = createCustomAsset({ approxPriceChf: 0 });

expect(asset.minimalPriceReferenceAmount).toBe(1);
});

it('falls back to 1 for a degenerate near-zero price instead of inverting it into an overflow', () => {
const asset = createCustomAsset({ approxPriceChf: 4.2421310463457016e-60 });

expect(asset.minimalPriceReferenceAmount).toBe(1);
});
});
});
10 changes: 9 additions & 1 deletion src/shared/models/asset/asset.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,16 @@ export class Asset extends IEntity {
@ManyToOne(() => PriceRule)
priceRule: PriceRule;

// prices outside these rails are corrupted data (e.g. from a degenerate DEX quote), not real prices
static readonly MIN_SANE_PRICE = 1e-12;
static readonly MAX_SANE_PRICE = 1e15;

static isSanePrice(price?: number): boolean {
return price != null && Number.isFinite(price) && price > Asset.MIN_SANE_PRICE && price < Asset.MAX_SANE_PRICE;
}

get minimalPriceReferenceAmount() {
return this.approxPriceChf ? 1 / this.approxPriceChf : 1;
return Asset.isSanePrice(this.approxPriceChf) ? 1 / this.approxPriceChf : 1;
}

get evmChainId(): number | undefined {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { createMock } from '@golevelup/ts-jest';
import { Test, TestingModule } from '@nestjs/testing';
import { createCustomAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock';
import { AssetType } from 'src/shared/models/asset/asset.entity';
import { AssetService } from 'src/shared/models/asset/asset.service';
import { FiatService } from 'src/shared/models/fiat/fiat.service';
import { DfxLogger } from 'src/shared/services/dfx-logger';
import { TestUtil } from 'src/shared/utils/test.util';
import { AssetPriceRepository } from '../../repositories/asset-price.repository';
import { AssetPricesJobService } from '../asset-prices-job.service';
import { PricingService } from '../pricing.service';

describe('AssetPricesJobService', () => {
let service: AssetPricesJobService;

let assetService: AssetService;
let fiatService: FiatService;
let pricingService: PricingService;
let assetPriceRepo: AssetPriceRepository;

beforeEach(async () => {
assetService = createMock<AssetService>();
fiatService = createMock<FiatService>();
pricingService = createMock<PricingService>();
assetPriceRepo = createMock<AssetPriceRepository>();

const module: TestingModule = await Test.createTestingModule({
providers: [
AssetPricesJobService,
{ provide: AssetService, useValue: assetService },
{ provide: FiatService, useValue: fiatService },
{ provide: PricingService, useValue: pricingService },
{ provide: AssetPriceRepository, useValue: assetPriceRepo },
TestUtil.provideConfig(),
],
}).compile();

service = module.get<AssetPricesJobService>(AssetPricesJobService);
});

afterEach(() => jest.restoreAllMocks());

const priceOf = (value: number) => ({ convert: () => value }) as any;

describe('#updatePrices', () => {
it('stores sane prices', async () => {
const asset = createCustomAsset({ id: 264, uniqueName: 'Arbitrum/TGT', type: AssetType.CUSTOM });
jest.spyOn(assetService, 'getPricedAssets').mockResolvedValue([asset]);
jest
.spyOn(pricingService, 'getPrice')
.mockResolvedValueOnce(priceOf(0.0075))
.mockResolvedValueOnce(priceOf(0.0061))
.mockResolvedValueOnce(priceOf(0.0064));

await service.updatePrices();

expect(assetService.updatePrices).toHaveBeenCalledWith([
[264, { approxPriceUsd: 0.0075, approxPriceChf: 0.0061, approxPriceEur: 0.0064 }],
]);
});

it('skips the update and warns when a fetched price is degenerate', async () => {
const warnSpy = jest.spyOn(DfxLogger.prototype, 'warn').mockImplementation(() => undefined);
const asset = createCustomAsset({
id: 264,
uniqueName: 'Arbitrum/TGT',
type: AssetType.CUSTOM,
approxPriceChf: 0.0061,
});
jest.spyOn(assetService, 'getPricedAssets').mockResolvedValue([asset]);
jest.spyOn(pricingService, 'getPrice').mockResolvedValue(priceOf(4.2421310463457016e-60));

await service.updatePrices();

expect(assetService.updatePrices).toHaveBeenCalledWith([]);
expect(asset.approxPriceChf).toBe(0.0061);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Arbitrum/TGT'));
});

it('still updates the remaining assets when one price is degenerate', async () => {
jest.spyOn(DfxLogger.prototype, 'warn').mockImplementation(() => undefined);
const degenerate = createCustomAsset({ id: 264, uniqueName: 'Arbitrum/TGT', type: AssetType.CUSTOM });
const sane = createCustomAsset({ id: 265, uniqueName: 'Arbitrum/USDT', type: AssetType.CUSTOM });
jest.spyOn(assetService, 'getPricedAssets').mockResolvedValue([degenerate, sane]);
jest
.spyOn(pricingService, 'getPrice')
.mockResolvedValueOnce(priceOf(4.2421310463457016e-60))
.mockResolvedValueOnce(priceOf(4.2421310463457016e-60))
.mockResolvedValueOnce(priceOf(4.2421310463457016e-60))
.mockResolvedValueOnce(priceOf(1.07))
.mockResolvedValueOnce(priceOf(0.86))
.mockResolvedValueOnce(priceOf(0.92));

await service.updatePrices();

expect(assetService.updatePrices).toHaveBeenCalledWith([
[265, { approxPriceUsd: 1.07, approxPriceChf: 0.86, approxPriceEur: 0.92 }],
]);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,16 @@ export class AssetPricesJobService {
const chfPrice = await this.pricingService.getPrice(asset, PriceCurrency.CHF, PriceValidity.VALID_ONLY);
const eurPrice = await this.pricingService.getPrice(asset, PriceCurrency.EUR, PriceValidity.VALID_ONLY);

updates.push(asset.updatePrice(usdPrice.convert(1), chfPrice.convert(1), eurPrice.convert(1)));
const [usd, chf, eur] = [usdPrice, chfPrice, eurPrice].map((p) => p.convert(1));

if (![usd, chf, eur].every((p) => Asset.isSanePrice(p))) {
this.logger.warn(
`Skipping price update of asset ${asset.uniqueName}: degenerate price (USD ${usd}, CHF ${chf}, EUR ${eur})`,
);
continue;
}

updates.push(asset.updatePrice(usd, chf, eur));

if ([AssetType.COIN, AssetType.TOKEN, AssetType.CUSTODY].includes(asset.type))
await this.saveAssetPrices(asset);
Expand Down