diff --git a/packages/blocknumber/package.json b/packages/blocknumber/package.json index 53e6369..c7ded21 100644 --- a/packages/blocknumber/package.json +++ b/packages/blocknumber/package.json @@ -17,7 +17,14 @@ "author": "", "license": "ISC", "dependencies": { - "viem": "2.17.10", - "@ebo-agent/shared": "workspace:*" + "@ebo-agent/shared": "workspace:*", + "axios": "1.7.7", + "jwt-decode": "4.0.0", + "viem": "2.17.10" + }, + "devDependencies": { + "@types/jsonwebtoken": "9.0.6", + "axios-mock-adapter": "2.0.0", + "jsonwebtoken": "9.0.2" } } diff --git a/packages/blocknumber/src/exceptions/blockmetaConnectionFailed.ts b/packages/blocknumber/src/exceptions/blockmetaConnectionFailed.ts new file mode 100644 index 0000000..bf41036 --- /dev/null +++ b/packages/blocknumber/src/exceptions/blockmetaConnectionFailed.ts @@ -0,0 +1,7 @@ +export class BlockmetaConnectionFailed extends Error { + constructor() { + super(`Could not establish connection to blockmeta.`); + + this.name = "BlockmetaConnectionFailed"; + } +} diff --git a/packages/blocknumber/src/exceptions/index.ts b/packages/blocknumber/src/exceptions/index.ts index db14d30..d343a09 100644 --- a/packages/blocknumber/src/exceptions/index.ts +++ b/packages/blocknumber/src/exceptions/index.ts @@ -1,3 +1,4 @@ +export * from "./blockmetaConnectionFailed.js"; export * from "./chainWithoutProvider.js"; export * from "./emptyRpcUrls.js"; export * from "./invalidChain.js"; @@ -8,3 +9,4 @@ export * from "./unexpectedSearchRange.js"; export * from "./unsupportedBlockNumber.js"; export * from "./unsupportedBlockTimestamps.js"; export * from "./unsupportedChain.js"; +export * from "./undefinedBlockNumber.js"; diff --git a/packages/blocknumber/src/exceptions/undefinedBlockNumber.ts b/packages/blocknumber/src/exceptions/undefinedBlockNumber.ts new file mode 100644 index 0000000..de4356e --- /dev/null +++ b/packages/blocknumber/src/exceptions/undefinedBlockNumber.ts @@ -0,0 +1,7 @@ +export class UndefinedBlockNumber extends Error { + constructor(isoTimestamp: string) { + super(`Undefined block number at ${isoTimestamp}.`); + + this.name = "UndefinedBlockNumber"; + } +} diff --git a/packages/blocknumber/src/providers/blockNumberProviderFactory.ts b/packages/blocknumber/src/providers/blockNumberProviderFactory.ts index 99360c0..4817980 100644 --- a/packages/blocknumber/src/providers/blockNumberProviderFactory.ts +++ b/packages/blocknumber/src/providers/blockNumberProviderFactory.ts @@ -4,6 +4,10 @@ import { FallbackTransport, HttpTransport, PublicClient } from "viem"; import { UnsupportedChain } from "../exceptions/unsupportedChain.js"; import { Caip2ChainId } from "../types.js"; import { Caip2Utils } from "../utils/index.js"; +import { + BlockmetaClientConfig, + BlockmetaJsonBlockNumberProvider, +} from "./blockmetaJsonBlockNumberProvider.js"; import { EvmBlockNumberProvider } from "./evmBlockNumberProvider.js"; const DEFAULT_PROVIDER_CONFIG = { @@ -16,20 +20,26 @@ export class BlockNumberProviderFactory { * Build a `BlockNumberProvider` to handle communication with the specified chain. * * @param chainId CAIP-2 chain id - * @param client a viem public client + * @param evmClient a viem public client * @param logger a ILogger instance * @returns */ public static buildProvider( chainId: Caip2ChainId, - client: PublicClient>, + evmClient: PublicClient>, + blockmetaConfig: BlockmetaClientConfig, logger: ILogger, ) { + // TODO: initialize factory instance with evmClient and blockmetaConfig and + // remove them from this method parameters const chainNamespace = Caip2Utils.getNamespace(chainId); switch (chainNamespace) { case EBO_SUPPORTED_CHAINS_CONFIG.evm.namespace: - return new EvmBlockNumberProvider(client, DEFAULT_PROVIDER_CONFIG, logger); + return new EvmBlockNumberProvider(evmClient, DEFAULT_PROVIDER_CONFIG, logger); + + case EBO_SUPPORTED_CHAINS_CONFIG.solana.namespace: + return new BlockmetaJsonBlockNumberProvider(blockmetaConfig, logger); default: throw new UnsupportedChain(chainId); diff --git a/packages/blocknumber/src/providers/blockmetaJsonBlockNumberProvider.ts b/packages/blocknumber/src/providers/blockmetaJsonBlockNumberProvider.ts new file mode 100644 index 0000000..d5fc4c2 --- /dev/null +++ b/packages/blocknumber/src/providers/blockmetaJsonBlockNumberProvider.ts @@ -0,0 +1,214 @@ +import { ILogger, Timestamp } from "@ebo-agent/shared"; +import axios, { + AxiosInstance, + AxiosResponse, + InternalAxiosRequestConfig, + isAxiosError, +} from "axios"; +import { InvalidTokenError, jwtDecode } from "jwt-decode"; + +import { BlockmetaConnectionFailed } from "../exceptions/blockmetaConnectionFailed.js"; +import { UndefinedBlockNumber } from "../exceptions/undefinedBlockNumber.js"; +import { BlockNumberProvider } from "./blockNumberProvider.js"; + +type BlockByTimeResponse = { + num: string; + id: string; + time: string; +}; + +export type BlockmetaClientConfig = { + baseUrl: URL; + servicePaths: { + block: string; + blockByTime: string; + }; + bearerToken: string; + bearerTokenExpirationWindow: number; +}; + +/** + * Consumes the blockmeta.BlockByTime substreams' service via HTTP POST JSON requests to provide + * block numbers based on timestamps + * + * Refer to these web pages for more information: + * * https://thegraph.market/ + * * https://substreams.streamingfast.io/documentation/consume/authentication + */ +export class BlockmetaJsonBlockNumberProvider implements BlockNumberProvider { + private readonly axios: AxiosInstance; + + constructor( + private readonly clientConfig: BlockmetaClientConfig, + private readonly logger: ILogger, + ) { + const { baseUrl, bearerToken } = clientConfig; + + this.axios = axios.create({ + baseURL: baseUrl.toString(), + headers: { + common: { + "Content-Type": "application/json", + Authorization: `Bearer ${bearerToken}`, + }, + }, + }); + + this.axios.interceptors.request.use((config) => { + return this.validateBearerToken(config); + }); + } + + public static async initialize( + config: BlockmetaClientConfig, + logger: ILogger, + ): Promise { + const provider = new BlockmetaJsonBlockNumberProvider(config, logger); + + const connectedSuccessfully = await provider.testConnection(); + + if (!connectedSuccessfully) throw new BlockmetaConnectionFailed(); + + return provider; + } + + async testConnection(): Promise { + const blockPath = this.clientConfig.servicePaths.block; + + try { + await this.axios.post(`${blockPath}/Head`); + + return true; + } catch (err) { + if (isAxiosError(err)) return false; + + throw err; + } + } + + private validateBearerToken(requestConfig: InternalAxiosRequestConfig) { + const authorizationHeader = requestConfig.headers.Authorization?.toString(); + const matches = /^Bearer (.*)$/.exec(authorizationHeader || ""); + const token = matches ? matches[1] : undefined; + + try { + const decodedToken = jwtDecode(token || ""); + const currentTime = Date.now(); + const expTime = decodedToken.exp; + + // No expiration time, token will be forever valid. + if (!expTime) { + this.logger.debug("JWT token being used has no expiration time."); + + return requestConfig; + } + + const expirationWindow = this.clientConfig.bearerTokenExpirationWindow; + + if (currentTime + expirationWindow >= expTime) { + const timeRemaining = expTime - currentTime; + + this.logger.warn(`Token will expire soon in ${timeRemaining}`); + + // TODO: notify + } + + return requestConfig; + } catch (err) { + if (err instanceof InvalidTokenError) { + this.logger.error("Invalid JWT token."); + + // TODO: notify + } + + return requestConfig; + } + } + + /** @inheritdoc */ + async getEpochBlockNumber(timestamp: Timestamp): Promise { + if (timestamp > Number.MAX_SAFE_INTEGER || timestamp < 0) + throw new RangeError(`Timestamp ${timestamp.toString()} cannot be casted to a Number.`); + + const timestampNumber = Number(timestamp); + const timestampDate = new Date(timestampNumber); + + try { + // Try to get the block number at a specific timestamp + const blockNumberAt = await this.getBlockNumberAt(timestampDate); + + return blockNumberAt; + } catch (err) { + const isAxios404 = isAxiosError(err) && err.response?.status === 404; + const isUndefinedBlockNumber = !!(err instanceof UndefinedBlockNumber); + + if (!isAxios404 && !isUndefinedBlockNumber) throw err; + + // If no block has its timestamp exactly equal to the specified timestamp, + // try to get the most recent block before the specified timestamp. + const blockNumberBefore = await this.getBlockNumberBefore(timestampDate); + + return blockNumberBefore; + } + } + + /** + * Gets the block number at a specific timestamp. + * + * @param date timestamp date + * @throws { UndefinedBlockNumber } if request was successful but block number is invalid/not present + * @throws { AxiosError } if request fails + * @returns a promise with the block number at the timestamp + */ + private async getBlockNumberAt(date: Date): Promise { + const isoTimestamp = date.toISOString(); + + const blockByTimePath = this.clientConfig.servicePaths.blockByTime; + + const response = await this.axios.post(`${blockByTimePath}/At`, { time: isoTimestamp }); + + return this.parseBlockByTimeResponse(response, isoTimestamp); + } + + /** + * Gets the most recent block number before the specified timestamp. + * + * @param date timestamp date + * @throws { UndefinedBlockNumber } if request was successful but block number is invalid/not present + * @throws { AxiosError } if request fails + * @returns a promise with the most recent block number before the specified timestamp + */ + private async getBlockNumberBefore(date: Date): Promise { + const isoTimestamp = date.toISOString(); + + const blockByTimePath = this.clientConfig.servicePaths.blockByTime; + + const response = await this.axios.post(`${blockByTimePath}/Before`, { time: isoTimestamp }); + + return this.parseBlockByTimeResponse(response, isoTimestamp); + } + + /** + * Parse the BlockByTime response and extracts the block number. + * + * @param response an AxiosResponse of a request to BlockByTime endpoint + * @param isoTimestamp the timestamp that was sent in the request + * @returns the block number inside a BlockByTime service response + */ + private parseBlockByTimeResponse( + response: AxiosResponse, + isoTimestamp: string, + ): bigint { + const { data } = response; + // TODO: validate with zod instead + const blockNumber = (data as BlockByTimeResponse)["num"]; + + if (blockNumber === undefined) { + this.logger.error(`Couldn't find a block number for timestamp ${isoTimestamp}`); + + throw new UndefinedBlockNumber(isoTimestamp); + } + + return BigInt(blockNumber); + } +} diff --git a/packages/blocknumber/src/providers/index.ts b/packages/blocknumber/src/providers/index.ts new file mode 100644 index 0000000..e903779 --- /dev/null +++ b/packages/blocknumber/src/providers/index.ts @@ -0,0 +1,4 @@ +export * from "./blockNumberProvider.js"; +export * from "./blockNumberProviderFactory.js"; +export * from "./blockmetaJsonBlockNumberProvider.js"; +export * from "./evmBlockNumberProvider.js"; diff --git a/packages/blocknumber/src/services/blockNumberService.ts b/packages/blocknumber/src/services/blockNumberService.ts index 1dbd1e2..943e634 100644 --- a/packages/blocknumber/src/services/blockNumberService.ts +++ b/packages/blocknumber/src/services/blockNumberService.ts @@ -2,6 +2,7 @@ import { EBO_SUPPORTED_CHAIN_IDS, ILogger, Timestamp } from "@ebo-agent/shared"; import { createPublicClient, fallback, http } from "viem"; import { ChainWithoutProvider, EmptyRpcUrls, UnsupportedChain } from "../exceptions/index.js"; +import { BlockmetaClientConfig } from "../providers/blockmetaJsonBlockNumberProvider.js"; import { BlockNumberProvider } from "../providers/blockNumberProvider.js"; import { BlockNumberProviderFactory } from "../providers/blockNumberProviderFactory.js"; import { Caip2ChainId } from "../types.js"; @@ -20,6 +21,7 @@ export class BlockNumberService { */ constructor( chainRpcUrls: Map, + private readonly blockmetaConfig: BlockmetaClientConfig, private readonly logger: ILogger, ) { this.blockNumberProviders = this.buildBlockNumberProviders(chainRpcUrls); @@ -81,7 +83,12 @@ export class BlockNumberService { transport: fallback(urls.map((url) => http(url))), }); - const provider = BlockNumberProviderFactory.buildProvider(chainId, client, this.logger); + const provider = BlockNumberProviderFactory.buildProvider( + chainId, + client, + this.blockmetaConfig, + this.logger, + ); if (!provider) throw new ChainWithoutProvider(chainId); diff --git a/packages/blocknumber/test/providers/blockNumberProviderFactory.spec.ts b/packages/blocknumber/test/providers/blockNumberProviderFactory.spec.ts index 9c192ec..33092f0 100644 --- a/packages/blocknumber/test/providers/blockNumberProviderFactory.spec.ts +++ b/packages/blocknumber/test/providers/blockNumberProviderFactory.spec.ts @@ -1,29 +1,69 @@ import { Logger } from "@ebo-agent/shared"; -import { createPublicClient, fallback, http } from "viem"; +import { + createPublicClient, + fallback, + FallbackTransport, + http, + HttpTransport, + PublicClient, +} from "viem"; import { describe, expect, it } from "vitest"; -import { UnsupportedChain } from "../../src/exceptions"; -import { BlockNumberProviderFactory } from "../../src/providers/blockNumberProviderFactory"; -import { EvmBlockNumberProvider } from "../../src/providers/evmBlockNumberProvider"; -import { Caip2ChainId } from "../../src/types"; +import { UnsupportedChain } from "../../src/exceptions/index.js"; +import { + BlockmetaClientConfig, + BlockmetaJsonBlockNumberProvider, + BlockNumberProviderFactory, + EvmBlockNumberProvider, +} from "../../src/providers/index.js"; +import { Caip2ChainId } from "../../src/types.js"; describe("BlockNumberProviderFactory", () => { const logger = Logger.getInstance(); - describe("buildProvider", () => { - const client = createPublicClient({ transport: fallback([http("http://localhost:8545")]) }); + const client: PublicClient> = createPublicClient({ + transport: fallback([http("http://localhost:8545")]), + }); - it("builds a provider", () => { - const provider = BlockNumberProviderFactory.buildProvider("eip155:1", client, logger); + const blockmetaConfig: BlockmetaClientConfig = { + baseUrl: new URL("localhost:443"), + servicePath: "/sf.blockmeta.v2.BlockByTime", + bearerToken: "bearer-token", + }; + + describe("buildProvider", () => { + it("builds an EVM provider", () => { + const provider = BlockNumberProviderFactory.buildProvider( + "eip155:1", + client, + blockmetaConfig, + logger, + ); expect(provider).toBeInstanceOf(EvmBlockNumberProvider); }); + it("builds a Solana Blockmeta provider", () => { + const provider = BlockNumberProviderFactory.buildProvider( + "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + client, + blockmetaConfig, + logger, + ); + + expect(provider).toBeInstanceOf(BlockmetaJsonBlockNumberProvider); + }); + it("fails if chain is not supported", () => { - const unsupportedChainId = "solana:80085" as Caip2ChainId; + const unsupportedChainId = "antelope:f16b1833c747c43682f4386fca9cbb32" as Caip2ChainId; expect(() => { - BlockNumberProviderFactory.buildProvider(unsupportedChainId, client, logger); + BlockNumberProviderFactory.buildProvider( + unsupportedChainId, + client, + blockmetaConfig, + logger, + ); }).toThrow(UnsupportedChain); }); }); diff --git a/packages/blocknumber/test/providers/blockmetaBlockNumberProvider.spec.ts b/packages/blocknumber/test/providers/blockmetaBlockNumberProvider.spec.ts new file mode 100644 index 0000000..a668c7e --- /dev/null +++ b/packages/blocknumber/test/providers/blockmetaBlockNumberProvider.spec.ts @@ -0,0 +1,290 @@ +import { ILogger } from "@ebo-agent/shared"; +import MockAxiosAdapter from "axios-mock-adapter"; +import jwt from "jsonwebtoken"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { BlockmetaConnectionFailed, UndefinedBlockNumber } from "../../src/exceptions/index.js"; +import { BlockmetaJsonBlockNumberProvider } from "../../src/providers/index.js"; + +describe("BlockmetaBlockNumberService", () => { + const config = { + baseUrl: new URL("localhost:443"), + bearerToken: jwt.sign({}, "secret", { expiresIn: "10y" }), + bearerTokenExpirationWindow: 365 * 24 * 60 * 60 * 1000, // 1 year + servicePaths: { + blockByTime: "/sf.blockmeta.v2.BlockByTime", + block: "/sf.blockmeta.v2.Block", + }, + }; + + let logger: ILogger; + + beforeEach(() => { + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + }); + + describe("initialize", () => { + it("returns a validated provider", async () => { + const mockTestConnection = vi + .spyOn(BlockmetaJsonBlockNumberProvider.prototype, "testConnection") + .mockResolvedValue(true); + + const provider = await BlockmetaJsonBlockNumberProvider.initialize(config, logger); + + expect(provider).toBeInstanceOf(BlockmetaJsonBlockNumberProvider); + + mockTestConnection.mockRestore(); + }); + + it("throws if could not establish connection", () => { + const mockTestConnection = vi + .spyOn(BlockmetaJsonBlockNumberProvider.prototype, "testConnection") + .mockResolvedValue(false); + + expect(BlockmetaJsonBlockNumberProvider.initialize(config, logger)).rejects.toThrow( + BlockmetaConnectionFailed, + ); + + mockTestConnection.mockRestore(); + }); + }); + + describe("testConnection", () => { + it("returns true if connection was established successfully", async () => { + const provider = new BlockmetaJsonBlockNumberProvider(config, logger); + + const mockProviderAxios = new MockAxiosAdapter(provider["axios"]); + + mockProviderAxios + .onPost(`${config.servicePaths.block}/Head`, undefined, { + headers: expect.objectContaining({ + Authorization: `Bearer ${config.bearerToken}`, + "Content-Type": "application/json", + }), + }) + .reply(200, { + id: "123abc", + num: "1", + time: "2024-01-01T00:00:00.000Z", + }); + + const result = await provider.testConnection(); + + expect(result).toBe(true); + }); + + it("returns false if connection was not established", async () => { + const provider = new BlockmetaJsonBlockNumberProvider(config, logger); + + const mockProviderAxios = new MockAxiosAdapter(provider["axios"]); + + mockProviderAxios + .onPost(`${config.servicePaths.block}/Head`, undefined, { + headers: expect.objectContaining({ + Authorization: `Bearer ${config.bearerToken}`, + "Content-Type": "application/json", + }), + }) + .reply(401); + + const result = await provider.testConnection(); + + expect(result).toBe(false); + }); + + it("warns if the token is expiring soon", async () => { + const expirationWindow = 1 * 60 * 60 * 1000; // 1 day; + const token = jwt.sign({}, "secret-key", { + expiresIn: "1h", + }); + + const expiringSoonConfig = { + ...config, + bearerToken: token, + bearerTokenExpirationWindow: expirationWindow, + }; + + const provider = new BlockmetaJsonBlockNumberProvider(expiringSoonConfig, logger); + + const mockProviderAxios = new MockAxiosAdapter(provider["axios"]); + + mockProviderAxios + .onPost(`${config.servicePaths.block}/Head`, undefined, { + headers: expect.objectContaining({ + Authorization: `Bearer ${config.bearerToken}`, + "Content-Type": "application/json", + }), + }) + .reply(200); + + await provider.testConnection(); + + expect(logger.warn).toHaveBeenCalledOnce(); + }); + + it.todo("notifies if the token is expiring soon"); + }); + + describe("getEpochBlockNumber", () => { + it("returns the blocknumber from the blockmeta service", async () => { + const provider = new BlockmetaJsonBlockNumberProvider(config, logger); + const mockProviderAxios = new MockAxiosAdapter(provider["axios"]); + const timestamp = BigInt(Date.UTC(2024, 0, 1, 0, 0, 0, 0)); + const blockNumber = 100n; + + mockProviderAxios + .onPost( + `${config.servicePaths.blockByTime}/At`, + { + time: "2024-01-01T00:00:00.000Z", + }, + { + headers: expect.objectContaining({ + Authorization: `Bearer ${config.bearerToken}`, + "Content-Type": "application/json", + }), + }, + ) + .reply(200, { + id: "123abc", + num: blockNumber.toString(), + time: "2024-01-01T00:00:00.000Z", + }); + + const result = await provider.getEpochBlockNumber(timestamp); + + expect(result).toEqual(blockNumber); + }); + + it("fetches block number before timestamp if At call fails", async () => { + const provider = new BlockmetaJsonBlockNumberProvider(config, logger); + const mockProviderAxios = new MockAxiosAdapter(provider["axios"]); + const timestamp = BigInt(Date.UTC(2024, 0, 1, 0, 0, 0, 0)); + const blockNumber = 100n; + + mockProviderAxios + .onPost( + `${config.servicePaths.blockByTime}/At`, + { + time: "2024-01-01T00:00:00.000Z", + }, + { + headers: expect.objectContaining({ + Authorization: `Bearer ${config.bearerToken}`, + "Content-Type": "application/json", + }), + }, + ) + .reply(404) + .onPost( + `${config.servicePaths.blockByTime}/Before`, + { + time: "2024-01-01T00:00:00.000Z", + }, + { + headers: expect.objectContaining({ + Authorization: `Bearer ${config.bearerToken}`, + "Content-Type": "application/json", + }), + }, + ) + .reply(200, { + id: "123abc", + num: blockNumber.toString(), + time: "2024-01-01T00:00:00.000Z", + }); + + const result = await provider.getEpochBlockNumber(timestamp); + + expect(result).toEqual(blockNumber); + }); + + it("throws if response has no block number", () => { + const provider = new BlockmetaJsonBlockNumberProvider(config, logger); + const mockProviderAxios = new MockAxiosAdapter(provider["axios"]); + const timestamp = BigInt(Date.UTC(2024, 0, 1, 0, 0, 0, 0)); + + mockProviderAxios + .onPost( + `${config.servicePaths.blockByTime}/At`, + { + time: "2024-01-01T00:00:00.000Z", + }, + { + headers: expect.objectContaining({ + Authorization: `Bearer ${config.bearerToken}`, + "Content-Type": "application/json", + }), + }, + ) + .reply(404) + .onPost( + `${config.servicePaths.blockByTime}/Before`, + { + time: "2024-01-01T00:00:00.000Z", + }, + { + headers: expect.objectContaining({ + Authorization: `Bearer ${config.bearerToken}`, + "Content-Type": "application/json", + }), + }, + ) + .reply(200, { + id: "123abc", + time: "2024-01-01T00:00:00.000Z", + }); + + expect(provider.getEpochBlockNumber(timestamp)).rejects.toThrow(UndefinedBlockNumber); + }); + + it("throws when timestamp is too big", () => { + const provider = new BlockmetaJsonBlockNumberProvider(config, logger); + const bigTimestamp = BigInt(Number.MAX_SAFE_INTEGER) + 1n; + + expect(provider.getEpochBlockNumber(bigTimestamp)).rejects.toThrow(RangeError); + }); + + it("throws when timestamp is too small", () => { + const provider = new BlockmetaJsonBlockNumberProvider(config, logger); + const bigTimestamp = -1n; + + expect(provider.getEpochBlockNumber(bigTimestamp)).rejects.toThrow(RangeError); + }); + + it("warns if the token is expiring soon", async () => { + const expirationWindow = 1 * 60 * 60 * 1000; // 1 day; + const token = jwt.sign({}, "secret-key", { + expiresIn: "1h", + }); + + const expiringSoonConfig = { + ...config, + bearerToken: token, + bearerTokenExpirationWindow: expirationWindow, + }; + + const provider = new BlockmetaJsonBlockNumberProvider(expiringSoonConfig, logger); + + const timestamp = BigInt(Date.UTC(2024, 0, 1, 0, 0, 0, 0)); + const mockProviderAxios = new MockAxiosAdapter(provider["axios"]); + + mockProviderAxios.onPost(`${config.servicePaths.blockByTime}/At`).reply(200, { + id: "123abc", + num: "1", + time: "2024-01-01T00:00:00.000Z", + }); + + await provider.getEpochBlockNumber(timestamp); + + expect(logger.warn).toHaveBeenCalledOnce(); + }); + + it.todo("notifies if the token is expiring soon"); + }); +}); diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 55d9b77..33c3230 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -7,6 +7,12 @@ export const EBO_SUPPORTED_CHAINS_CONFIG = { arbitrum: "42161", }, }, + solana: { + namespace: "solana", + references: { + mainnet: "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + }, + }, } as const; export const EBO_SUPPORTED_CHAIN_IDS = Object.values(EBO_SUPPORTED_CHAINS_CONFIG).reduce( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c07e656..b9c7615 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -92,9 +92,25 @@ importers: "@ebo-agent/shared": specifier: workspace:* version: link:../shared + axios: + specifier: 1.7.7 + version: 1.7.7 + jwt-decode: + specifier: 4.0.0 + version: 4.0.0 viem: specifier: 2.17.10 version: 2.17.10(typescript@5.5.3) + devDependencies: + "@types/jsonwebtoken": + specifier: 9.0.6 + version: 9.0.6 + axios-mock-adapter: + specifier: 2.0.0 + version: 2.0.0(axios@1.7.7) + jsonwebtoken: + specifier: 9.0.2 + version: 9.0.2 packages/shared: dependencies: @@ -962,6 +978,12 @@ packages: integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==, } + "@types/jsonwebtoken@9.0.6": + resolution: + { + integrity: sha512-/5hndP5dCjloafCXns6SZyESp3Ldq7YjH3zwzwczYnjxIT0Fqzk5ROSYVGfFyczIue7IUEj8hkvLbPoLQ18vQw==, + } + "@types/node@20.14.12": resolution: { @@ -1254,6 +1276,26 @@ packages: integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==, } + asynckit@0.4.0: + resolution: + { + integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==, + } + + axios-mock-adapter@2.0.0: + resolution: + { + integrity: sha512-D/K0J5Zm6KvaMTnsWrBQZWLzKN9GxUFZEa0mx2qeEHXDeTugCoplWehy8y36dj5vuSjhe1u/Dol8cZ8lzzmDew==, + } + peerDependencies: + axios: ">= 0.17.0" + + axios@1.7.7: + resolution: + { + integrity: sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==, + } + balanced-match@1.0.2: resolution: { @@ -1287,6 +1329,12 @@ packages: engines: { node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7 } hasBin: true + buffer-equal-constant-time@1.0.1: + resolution: + { + integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==, + } + cac@6.7.14: resolution: { @@ -1412,6 +1460,13 @@ packages: integrity: sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==, } + combined-stream@1.0.8: + resolution: + { + integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==, + } + engines: { node: ">= 0.8" } + commander@12.1.0: resolution: { @@ -1527,6 +1582,13 @@ packages: integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==, } + delayed-stream@1.0.0: + resolution: + { + integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==, + } + engines: { node: ">=0.4.0" } + diff@4.0.2: resolution: { @@ -1561,6 +1623,12 @@ packages: integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==, } + ecdsa-sig-formatter@1.0.11: + resolution: + { + integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==, + } + electron-to-chromium@1.5.13: resolution: { @@ -1838,6 +1906,18 @@ packages: integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==, } + follow-redirects@1.15.9: + resolution: + { + integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==, + } + engines: { node: ">=4.0" } + peerDependencies: + debug: "*" + peerDependenciesMeta: + debug: + optional: true + foreground-child@3.3.0: resolution: { @@ -1845,6 +1925,13 @@ packages: } engines: { node: ">=14" } + form-data@4.0.0: + resolution: + { + integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==, + } + engines: { node: ">= 6" } + fs.realpath@1.0.0: resolution: { @@ -2064,6 +2151,13 @@ packages: integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==, } + is-buffer@2.0.5: + resolution: + { + integrity: sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==, + } + engines: { node: ">=4" } + is-extglob@2.1.1: resolution: { @@ -2262,6 +2356,32 @@ packages: } engines: { "0": node >= 0.2.0 } + jsonwebtoken@9.0.2: + resolution: + { + integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==, + } + engines: { node: ">=12", npm: ">=6" } + + jwa@1.4.1: + resolution: + { + integrity: sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==, + } + + jws@3.2.2: + resolution: + { + integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==, + } + + jwt-decode@4.0.0: + resolution: + { + integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==, + } + engines: { node: ">=18" } + keyv@4.5.4: resolution: { @@ -2329,12 +2449,42 @@ packages: integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==, } + lodash.includes@4.3.0: + resolution: + { + integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==, + } + + lodash.isboolean@3.0.3: + resolution: + { + integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==, + } + + lodash.isinteger@4.0.4: + resolution: + { + integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==, + } + + lodash.isnumber@3.0.3: + resolution: + { + integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==, + } + lodash.isplainobject@4.0.6: resolution: { integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==, } + lodash.isstring@4.0.1: + resolution: + { + integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==, + } + lodash.kebabcase@4.1.1: resolution: { @@ -2353,6 +2503,12 @@ packages: integrity: sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==, } + lodash.once@4.1.1: + resolution: + { + integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==, + } + lodash.snakecase@4.1.1: resolution: { @@ -2461,6 +2617,20 @@ packages: } engines: { node: ">=8.6" } + mime-db@1.52.0: + resolution: + { + integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==, + } + engines: { node: ">= 0.6" } + + mime-types@2.1.35: + resolution: + { + integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==, + } + engines: { node: ">= 0.6" } + mimic-fn@4.0.0: resolution: { @@ -2733,6 +2903,12 @@ packages: engines: { node: ">=14" } hasBin: true + proxy-from-env@1.1.0: + resolution: + { + integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==, + } + punycode@2.3.1: resolution: { @@ -3987,6 +4163,10 @@ snapshots: "@types/estree@1.0.5": {} + "@types/jsonwebtoken@9.0.6": + dependencies: + "@types/node": 20.14.12 + "@types/node@20.14.12": dependencies: undici-types: 5.26.5 @@ -4198,6 +4378,22 @@ snapshots: async@3.2.6: {} + asynckit@0.4.0: {} + + axios-mock-adapter@2.0.0(axios@1.7.7): + dependencies: + axios: 1.7.7 + fast-deep-equal: 3.1.3 + is-buffer: 2.0.5 + + axios@1.7.7: + dependencies: + follow-redirects: 1.15.9 + form-data: 4.0.0 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + balanced-match@1.0.2: {} brace-expansion@1.1.11: @@ -4220,6 +4416,8 @@ snapshots: node-releases: 2.0.18 update-browserslist-db: 1.1.0(browserslist@4.23.3) + buffer-equal-constant-time@1.0.1: {} + cac@6.7.14: {} callsites@3.1.0: {} @@ -4293,6 +4491,10 @@ snapshots: color: 3.2.1 text-hex: 1.0.0 + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + commander@12.1.0: {} compare-func@2.0.0: @@ -4353,6 +4555,8 @@ snapshots: deep-is@0.1.4: {} + delayed-stream@1.0.0: {} + diff@4.0.2: {} dir-glob@3.0.1: @@ -4369,6 +4573,10 @@ snapshots: eastasianwidth@0.2.0: {} + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + electron-to-chromium@1.5.13: {} emoji-regex@10.4.0: {} @@ -4571,11 +4779,19 @@ snapshots: fn.name@1.1.0: {} + follow-redirects@1.15.9: {} + foreground-child@3.3.0: dependencies: cross-spawn: 7.0.3 signal-exit: 4.1.0 + form-data@4.0.0: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + mime-types: 2.1.35 + fs.realpath@1.0.0: {} fsevents@2.3.3: @@ -4680,6 +4896,8 @@ snapshots: is-arrayish@0.3.2: {} + is-buffer@2.0.5: {} + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -4765,6 +4983,32 @@ snapshots: jsonparse@1.3.1: {} + jsonwebtoken@9.0.2: + dependencies: + jws: 3.2.2 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.6.3 + + jwa@1.4.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@3.2.2: + dependencies: + jwa: 1.4.1 + safe-buffer: 5.2.1 + + jwt-decode@4.0.0: {} + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -4814,14 +5058,26 @@ snapshots: lodash.camelcase@4.3.0: {} + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + lodash.isplainobject@4.0.6: {} + lodash.isstring@4.0.1: {} + lodash.kebabcase@4.1.1: {} lodash.merge@4.6.2: {} lodash.mergewith@4.6.2: {} + lodash.once@4.1.1: {} + lodash.snakecase@4.1.1: {} lodash.startcase@4.4.0: {} @@ -4884,6 +5140,12 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + mimic-fn@4.0.0: {} mimic-function@5.0.1: {} @@ -5009,6 +5271,8 @@ snapshots: prettier@3.3.3: {} + proxy-from-env@1.1.0: {} + punycode@2.3.1: {} queue-microtask@1.2.3: {}