diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml index f9c3504..1aef8b7 100644 --- a/.github/workflows/coverage.yaml +++ b/.github/workflows/coverage.yaml @@ -1,6 +1,9 @@ name: 'coverage' on: + push: + branches: + - master pull_request_target: branches: - master diff --git a/package.json b/package.json index 1b03614..7c7ce06 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "interbtc-indexer", "private": "true", - "version": "0.16.5", + "version": "0.16.6", "description": "GraphQL server and Substrate indexer for the interBTC parachain", "author": "", "license": "ISC", @@ -65,6 +65,9 @@ "jest" :{ "preset": "ts-jest", "testEnvironment": "node", + "modulePathIgnorePatterns": [ + "/lib/" + ], "collectCoverageFrom": [ "/src/**/*.ts*" ], diff --git a/src/mappings/_utils.test.ts b/src/mappings/_utils.test.ts new file mode 100644 index 0000000..887143c --- /dev/null +++ b/src/mappings/_utils.test.ts @@ -0,0 +1,211 @@ +import { ForeignAsset as LibForeignAsset } from "@interlay/interbtc-api"; +import { cacheForeignAssets, getForeignAsset, symbolFromCurrency, testHelpers } from "./_utils"; +import { ForeignAsset, NativeToken, Token } from "../model"; + +// mocking getForeignAsset and getForeignAssets in the lib (interBtcApi) +const libGetForeignAssetsMock = jest.fn(); +const libGetForeignAssetMock = jest.fn(); +const mockInterBtcApi = { + assetRegistry: { + getForeignAssets: () => libGetForeignAssetsMock(), + getForeignAsset: (id: number) => libGetForeignAssetMock(id) + } +}; + +// "default" good result mock implementation, should be set inside tests, too (will be reset between tests) +const getInterBtcApiMock = jest.fn().mockImplementation(() => Promise.resolve(mockInterBtcApi)); +jest.mock("./utils/interBtcApi", () => { + const originalModule = jest.requireActual("./utils/interBtcApi"); + + return { + __esModule: true, + ...originalModule, + getInterBtcApi: () => getInterBtcApiMock() + } +}); + +describe("_utils", () => { + // fake asset to play with + const fakeAsset = { + foreignAsset: { + id: 42, + coingeckoId: "foo" + }, + name: "FooCoin", + ticker:"FOO", + decimals: 7 + }; + + afterAll(() => { + jest.resetAllMocks(); + }); + + describe("getForeignAsset", () => { + beforeEach(() => { + // clean out cache + testHelpers.getForeignAssetsCache().clear(); + }); + + afterEach(() => { + libGetForeignAssetMock.mockReset(); + getInterBtcApiMock.mockReset(); + }); + + it("should fetch value from cache first", async () => { + libGetForeignAssetMock.mockImplementation(() => Promise.resolve(fakeAsset)); + getInterBtcApiMock.mockImplementation(() => Promise.resolve(mockInterBtcApi)); + + // relies on cache to have been passed as reference + testHelpers.getForeignAssetsCache().set(fakeAsset.foreignAsset.id, fakeAsset); + + const actualAsset = await getForeignAsset(fakeAsset.foreignAsset.id); + expect(actualAsset).toBe(fakeAsset); + expect(getInterBtcApiMock).not.toHaveBeenCalled(); + expect(libGetForeignAssetMock).not.toHaveBeenCalled(); + }); + + it("should fetch from lib and add to cache", async () => { + libGetForeignAssetMock.mockImplementation(() => Promise.resolve(fakeAsset)); + getInterBtcApiMock.mockImplementation(() => Promise.resolve(mockInterBtcApi)); + + // clean out cache + testHelpers.getForeignAssetsCache().clear(); + + const actualAsset = await getForeignAsset(fakeAsset.foreignAsset.id); + expect(getInterBtcApiMock).toHaveBeenCalledTimes(1); + expect(libGetForeignAssetMock).toHaveBeenCalledTimes(1); + + expect(actualAsset).toBe(fakeAsset); + + const cacheNow = testHelpers.getForeignAssetsCache(); + expect(cacheNow.size).toBe(1); + expect(cacheNow.has(fakeAsset.foreignAsset.id)).toBe(true); + }); + + it("should reject if getInterBtcApi rejects", async () => { + getInterBtcApiMock.mockImplementation(() => Promise.reject(Error("soz lol"))); + + await expect(getForeignAsset(fakeAsset.foreignAsset.id)).rejects.toThrow("soz lol"); + expect(getInterBtcApiMock).toHaveBeenCalledTimes(1); + expect(libGetForeignAssetMock).not.toHaveBeenCalled(); + }); + + it("should reject if assetRegistry.getForeignAssets rejects", async () => { + getInterBtcApiMock.mockImplementation(() => Promise.resolve(mockInterBtcApi)); + libGetForeignAssetMock.mockImplementation(() => Promise.reject(Error("computer says no"))); + + await expect(getForeignAsset(fakeAsset.foreignAsset.id)).rejects.toThrow("computer says no"); + expect(getInterBtcApiMock).toHaveBeenCalledTimes(1); + expect(libGetForeignAssetMock).toHaveBeenCalledTimes(1); + }); + }); + + describe("cacheForeignAssets", () => { + afterEach(() => { + libGetForeignAssetsMock.mockReset(); + getInterBtcApiMock.mockReset(); + }); + + it("should get all foreign assets from lib as expected", async () => { + const fakeAssets = [fakeAsset]; + libGetForeignAssetsMock.mockImplementation(() => Promise.resolve(fakeAssets)); + getInterBtcApiMock.mockImplementation(() => Promise.resolve(mockInterBtcApi)); + + await cacheForeignAssets(); + const actualCache = testHelpers.getForeignAssetsCache(); + + expect(getInterBtcApiMock).toHaveBeenCalledTimes(1); + expect(libGetForeignAssetsMock).toHaveBeenCalledTimes(1); + expect(actualCache.has(fakeAsset.foreignAsset.id)).toBe(true); + expect(actualCache.get(fakeAsset.foreignAsset.id)).toBe(fakeAsset); + }); + + it("should reject if getInterBtcApi rejects", async () => { + getInterBtcApiMock.mockImplementation(() => Promise.reject(Error("nope"))); + + await expect(cacheForeignAssets()).rejects.toThrow("nope"); + expect(getInterBtcApiMock).toHaveBeenCalledTimes(1); + expect(libGetForeignAssetsMock).not.toHaveBeenCalled(); + }); + + it("should reject if assetRegistry.getForeignAssets rejects", async () => { + libGetForeignAssetsMock.mockImplementation(() => Promise.reject(Error("no assets for you"))); + getInterBtcApiMock.mockImplementation(() => Promise.resolve(mockInterBtcApi)); + + await expect(cacheForeignAssets()).rejects.toThrow("no assets for you"); + expect(getInterBtcApiMock).toHaveBeenCalledTimes(1); + expect(libGetForeignAssetsMock).toHaveBeenCalledTimes(1); + }); + + it("should set usdt asset id if found", async () => { + const oldUsdtAssetId = testHelpers.getUsdtAssetId() | 0; + + const fakeUsdtAsset = { + foreignAsset: { + id: oldUsdtAssetId + 13, + coingeckoId: "usdt" + }, + name: "Tether USD", + ticker:"USDT", + decimals: 6 + }; + const fakeAssets = [ + fakeAsset, + fakeUsdtAsset, + ]; + + libGetForeignAssetsMock.mockImplementation(() => Promise.resolve(fakeAssets)); + getInterBtcApiMock.mockImplementation(() => Promise.resolve(mockInterBtcApi)); + + await cacheForeignAssets(); + + const newUsdtAssetId = testHelpers.getUsdtAssetId(); + expect(newUsdtAssetId).toBe(fakeUsdtAsset.foreignAsset.id); + }); + + it("should not set usdt asset id if not found", async () => { + const oldUsdtAssetId = testHelpers.getUsdtAssetId(); + + const fakeAssets = [ + fakeAsset, + ]; + + libGetForeignAssetsMock.mockImplementation(() => Promise.resolve(fakeAssets)); + getInterBtcApiMock.mockImplementation(() => Promise.resolve(mockInterBtcApi)); + + await cacheForeignAssets(); + + const newUsdtAssetId = testHelpers.getUsdtAssetId(); + expect(newUsdtAssetId).toBe(oldUsdtAssetId); + }); + }); + + describe("symbolFromCurrency", () => { + it("should return token name for native token", async () => { + const testNativeToken = new NativeToken({token: Token.KINT}); + const actualSymbol = await symbolFromCurrency(testNativeToken); + + expect(actualSymbol).toBe(Token.KINT); + }); + + it("should return ticker for foreign asset", async () => { + const testAssetId = fakeAsset.foreignAsset.id; + // prepare cache for lookup + testHelpers.getForeignAssetsCache().set(testAssetId, fakeAsset); + + const testForeignAsset = new ForeignAsset({asset: testAssetId}); + const actualSymbol = await symbolFromCurrency(testForeignAsset); + expect(actualSymbol).toBe(fakeAsset.ticker); + }); + + it("should return unknown for unhandled currency type", async () => { + const badTestCurrency = { + isTypeOf: "definitely not a valid type" + }; + + const actualSymbol = await symbolFromCurrency(badTestCurrency as any); + expect(actualSymbol).toBe("UNKNOWN"); + }); + + }); +}); \ No newline at end of file diff --git a/src/mappings/_utils.ts b/src/mappings/_utils.ts index cc1b509..23998a5 100644 --- a/src/mappings/_utils.ts +++ b/src/mappings/_utils.ts @@ -1,17 +1,18 @@ import { CurrencyExt, CurrencyIdentifier, currencyIdToMonetaryCurrency, FIXEDI128_SCALING_FACTOR, newCollateralBTCExchangeRate, newMonetaryAmount, StandardPooledTokenIdentifier } from "@interlay/interbtc-api"; -import { Bitcoin, ExchangeRate, InterBtc, Interlay, KBtc, Kintsugi, Kusama, Polkadot } from "@interlay/monetary-js"; -import { ApiPromise, WsProvider } from '@polkadot/api'; +import { Bitcoin, ExchangeRate } from "@interlay/monetary-js"; import { BigDecimal } from "@subsquid/big-decimal"; import { Store } from "@subsquid/typeorm-store"; import { Big, BigSource } from "big.js"; import * as process from "process"; import { LessThanOrEqual, Like } from "typeorm"; import { Currency, ForeignAsset, Height, Issue, OracleUpdate, OracleUpdateType, Redeem, Token, Vault } from "../model"; -import { Ctx, getInterBtcApi } from "../processor"; +import { Ctx } from "../processor"; +import { getInterBtcApi } from "./utils/interBtcApi"; import { VaultId as VaultIdV1021000 } from "../types/v1021000"; import { VaultId as VaultIdV15 } from "../types/v15"; import { VaultId as VaultIdV6 } from "../types/v6"; import { encodeLegacyVaultId, encodeVaultId } from "./encoding"; +import { ForeignAsset as LibForeignAsset } from "@interlay/interbtc-api"; export type eventArgs = { event: { args: true }; @@ -73,48 +74,45 @@ type AssetMetadata = { symbol: string; } -// This function uses the storage API to obtain the details directly from the -// WSS RPC provider for the correct chain -const cache: { [id: number]: AssetMetadata } = {}; +// simple cache for foreign assets by id +const cache: Map = new Map(); let usdtAssetId: number; -export async function cacheForeignAsset(): Promise { - try { - const wsProvider = new WsProvider(process.env.CHAIN_ENDPOINT); - const api = await ApiPromise.create({ provider: wsProvider, noInitWarn: true }); - const assets = await api.query.assetRegistry.metadata.entries(); - assets.forEach(([key, details]) => { - const id:number = Number(key.args[0]); - const assetDetails = details.toHuman() as AssetMetadata; - cache[id] = assetDetails; - if(assetDetails.symbol==='USDT') usdtAssetId = id; - }); - } catch (error) { - console.error(`Error getting foreign asset metadata: ${error}`); - throw error; - } -} +export async function cacheForeignAssets(): Promise { + const interBtcApi = await getInterBtcApi(); + const foreignAssets = await interBtcApi.assetRegistry.getForeignAssets(); + foreignAssets.forEach((asset) => { + const id = asset.foreignAsset.id; + cache.set(id, asset); -export async function getForeignAsset(id: number): Promise { - if (id in cache) { - return cache[id]; - } - try { - const wsProvider = new WsProvider(process.env.CHAIN_ENDPOINT); - const api = await ApiPromise.create({ provider: wsProvider, noInitWarn: true }); - const assets = await api.query.assetRegistry.metadata(id); - const assetsJSON = assets.toHuman(); - const metadata = assetsJSON as AssetMetadata; - console.debug(`Foreign Asset (${id}): ${JSON.stringify(metadata)}`); - cache[id] = metadata; - return metadata; - } catch (error) { - console.error(`Error getting foreign asset metadata: ${error}`); - throw error; + if(asset.ticker === 'USDT') { + usdtAssetId = id; + } + }); +} + +export async function getForeignAsset(id: number): Promise { + if (cache.has(id)) { + return cache.get(id)!; } + + const interBtcApi = await getInterBtcApi(); + const asset = await interBtcApi.assetRegistry.getForeignAsset(id); + + cache.set(id,asset); + + return asset; } +/** + * Helper methods to facilitate testing, use at own risk + */ +export const testHelpers = { + getForeignAssetsCache: () => cache, + getUsdtAssetId: () => usdtAssetId +}; + /* This function takes a currency object (could be native, could be foreign) and an amount (in the smallest unit, e.g. Planck) and returns a human friendly string with a reasonable accuracy (6 digits after the decimal point for BTC and 2 for @@ -155,13 +153,12 @@ export function divideByTenToTheNth(amount: bigint, n: number): number { } export async function symbolFromCurrency(currency: Currency): Promise { - let amountFriendly: number; switch(currency.isTypeOf) { case 'NativeToken': return currency.token; case 'ForeignAsset': const details = await getForeignAsset(currency.asset) - return details.symbol; + return details.ticker; default: return `UNKNOWN`; } diff --git a/src/mappings/utils/interBtcApi.test.ts b/src/mappings/utils/interBtcApi.test.ts new file mode 100644 index 0000000..e8ab51d --- /dev/null +++ b/src/mappings/utils/interBtcApi.test.ts @@ -0,0 +1,73 @@ +import { BitcoinNetwork } from "@interlay/interbtc-api"; +import { getInterBtcApi, testHelpers } from "./interBtcApi"; + +const createInterBtcApiMock = jest.fn(); +jest.mock("@interlay/interbtc-api", () => { + const originalModule = jest.requireActual("@interlay/interbtc-api"); + + return { + __esModule: true, + ...originalModule, + createInterBtcApi: (endpoint: string, network: BitcoinNetwork) => createInterBtcApiMock(endpoint, network) + } +}); + + +describe("interBtcApi", () => { + afterAll(() => { + jest.resetAllMocks(); + }); + + describe("getInterBtcApi", () => { + const connectedFakeApi = { + api: { + isConnected: true + } + }; + + const disconnectedFakeApi = { + api: { + isConnected: false + } + }; + + afterEach(() => { + createInterBtcApiMock.mockReset(); + }); + + it("should create a new instance", async () => { + createInterBtcApiMock.mockImplementation((_args) => Promise.resolve(connectedFakeApi)); + + // reset to undefined + testHelpers.unsafeSetInterBtcApi(undefined); + + const actualApi = await getInterBtcApi(); + + expect(actualApi).toBe(connectedFakeApi); + expect(actualApi.api.isConnected).toBe(true); + expect(createInterBtcApiMock).toHaveBeenCalledTimes(1); + }); + + it("should return connected instance if possible", async () => { + testHelpers.unsafeSetInterBtcApi(connectedFakeApi); + + const actualApi = await getInterBtcApi(); + + expect(actualApi).toBe(connectedFakeApi); + expect(actualApi.api.isConnected).toBe(true); + expect(createInterBtcApiMock).not.toHaveBeenCalled(); + }); + + it("should create new instance if existing one is disconnected", async () => { + createInterBtcApiMock.mockImplementation((_args) => Promise.resolve(connectedFakeApi)); + + testHelpers.unsafeSetInterBtcApi(disconnectedFakeApi); + + const actualApi = await getInterBtcApi(); + + expect(actualApi).toBe(connectedFakeApi); + expect(actualApi.api.isConnected).toBe(true); + expect(createInterBtcApiMock).toHaveBeenCalledTimes(1); + }); + }); +}); \ No newline at end of file diff --git a/src/mappings/utils/interBtcApi.ts b/src/mappings/utils/interBtcApi.ts new file mode 100644 index 0000000..b765adb --- /dev/null +++ b/src/mappings/utils/interBtcApi.ts @@ -0,0 +1,22 @@ +import { BitcoinNetwork, createInterBtcApi, InterBtcApi } from "@interlay/interbtc-api"; + +let interBtcApi: InterBtcApi | undefined = undefined; + +export const getInterBtcApi = async (): Promise => { + if (interBtcApi === undefined || !interBtcApi.api.isConnected) { + const PARACHAIN_ENDPOINT = process.env.CHAIN_ENDPOINT; + const BITCOIN_NETWORK = process.env.BITCOIN_NETWORK as BitcoinNetwork; + + interBtcApi = await createInterBtcApi(PARACHAIN_ENDPOINT!, BITCOIN_NETWORK!); + } + return interBtcApi; +}; + +/** + * Only exported for better testing, use at own risk + */ +export const testHelpers = { + unsafeSetInterBtcApi: (maybeApi: any) => { + interBtcApi = maybeApi as InterBtcApi | undefined; + } +} \ No newline at end of file diff --git a/src/mappings/utils/pools.ts b/src/mappings/utils/pools.ts index 6f80254..8525c4e 100644 --- a/src/mappings/utils/pools.ts +++ b/src/mappings/utils/pools.ts @@ -20,28 +20,25 @@ const DEX_GENERAL_FEE_DENOMINATOR: number = 10_000; // Replicated order from parachain code. // See https://github.com/interlay/interbtc/blob/4cf80ce563825d28d637067a8a63c1d9825be1f4/primitives/src/lib.rs#L492-L498 -const indexToCurrencyTypeMap: Map = new Map([ - [0, "NativeToken"], - [1, "ForeignAsset"], - [2, "LendToken"], - [3, "LpToken"], - [4, "StableLpToken"] +const currencyTypeToIndexMap = new Map([ + ["NativeToken", 0], + ["ForeignAsset", 1], + ["LendToken", 2], + ["LpToken", 3], + ["StableLpToken", 4] ]); -const currencyTypeToIndexMap = invertMap(indexToCurrencyTypeMap); // Replicated order from parachain code. // See also https://github.com/interlay/interbtc/blob/d48fee47e153291edb92525221545c2f4fa58501/primitives/src/lib.rs#L469-L476 -const indexToNativeTokenMap: Map = new Map([ - [0, Token.DOT], - [1, Token.IBTC], - [2, Token.INTR], - [10, Token.KSM], - [11, Token.KBTC], - [12, Token.KINT] +const nativeTokenToIndexMap: Map = new Map([ + [Token.DOT, 0], + [Token.IBTC, 1], + [Token.INTR, 2], + [Token.KSM, 10], + [Token.KBTC, 11], + [Token.KINT, 12] ]); -const nativeTokenToIndexMap = invertMap(indexToNativeTokenMap); - // poor man's stable pool id to currencies cache const stablePoolCurrenciesCache = new Map(); diff --git a/src/processor.ts b/src/processor.ts index 1877e71..b253938 100644 --- a/src/processor.ts +++ b/src/processor.ts @@ -50,8 +50,7 @@ import { } from "./mappings/event/tokens"; import * as heights from "./mappings/utils/heights"; import EntityBuffer from "./mappings/utils/entityBuffer"; -import { eventArgsData, cacheForeignAsset } from "./mappings/_utils"; -import { BitcoinNetwork, createInterBtcApi, InterBtcApi } from "@interlay/interbtc-api"; +import { eventArgsData, cacheForeignAssets } from "./mappings/_utils"; import { newMarket, updatedMarket, @@ -87,7 +86,7 @@ const eventArgsData: eventArgsData = { const circulatingSupplyArgs = {...eventArgsData, ...getCirculatingSupplyProcessRange()}; // initialise a cache with all the foreign assets -cacheForeignAsset(); +cacheForeignAssets(); const processor = new SubstrateBatchProcessor() .setDataSource({ archive, chain }) @@ -164,17 +163,6 @@ export type CallItem = Exclude< >; export type Ctx = BatchContext; -let interBtcApi: InterBtcApi | undefined = undefined; - -export const getInterBtcApi = async () => { - if (interBtcApi === undefined) { - const PARACHAIN_ENDPOINT = process.env.CHAIN_ENDPOINT; - const BITCOIN_NETWORK = process.env.BITCOIN_NETWORK as BitcoinNetwork; - - interBtcApi = await createInterBtcApi(PARACHAIN_ENDPOINT!, BITCOIN_NETWORK!); - } - return interBtcApi; -} processor.run(new TypeormDatabase({ stateSchema: "interbtc" }), async (ctx) => { type MappingsList = Array<{ filter: { name: string };