Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add blockmeta block number provider #37

Merged
merged 8 commits into from
Sep 13, 2024
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
11 changes: 9 additions & 2 deletions packages/blocknumber/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export class BlockmetaConnectionFailed extends Error {
constructor() {
super(`Could not establish connection to blockmeta.`);

this.name = "BlockmetaConnectionFailed";
}
}
2 changes: 2 additions & 0 deletions packages/blocknumber/src/exceptions/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./blockmetaConnectionFailed.js";
export * from "./chainWithoutProvider.js";
export * from "./emptyRpcUrls.js";
export * from "./invalidChain.js";
Expand All @@ -8,3 +9,4 @@ export * from "./unexpectedSearchRange.js";
export * from "./unsupportedBlockNumber.js";
export * from "./unsupportedBlockTimestamps.js";
export * from "./unsupportedChain.js";
export * from "./undefinedBlockNumber.js";
7 changes: 7 additions & 0 deletions packages/blocknumber/src/exceptions/undefinedBlockNumber.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export class UndefinedBlockNumber extends Error {
constructor(isoTimestamp: string) {
super(`Undefined block number at ${isoTimestamp}.`);

this.name = "UndefinedBlockNumber";
}
}
16 changes: 13 additions & 3 deletions packages/blocknumber/src/providers/blockNumberProviderFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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<FallbackTransport<HttpTransport[]>>,
evmClient: PublicClient<FallbackTransport<HttpTransport[]>>,
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:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solana 👀 going shopping for those 200GB ram for running validators xd

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

xd

return new BlockmetaJsonBlockNumberProvider(blockmetaConfig, logger);

default:
throw new UnsupportedChain(chainId);
Expand Down
214 changes: 214 additions & 0 deletions packages/blocknumber/src/providers/blockmetaJsonBlockNumberProvider.ts
Original file line number Diff line number Diff line change
@@ -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";
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess we should import these from index.js but we can clean up later

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, got a task already for that! 👌

import { BlockNumberProvider } from "./blockNumberProvider.js";

type BlockByTimeResponse = {
num: string;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe this should be blockNum so it's a bit more descriptive--I had to see where it's being used to understand the type

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is based on the blockmeta BlockByTime service response, sadly we cannot change it as the keys are defined by the third-party service.

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<BlockmetaJsonBlockNumberProvider> {
const provider = new BlockmetaJsonBlockNumberProvider(config, logger);

const connectedSuccessfully = await provider.testConnection();

if (!connectedSuccessfully) throw new BlockmetaConnectionFailed();

return provider;
}

async testConnection(): Promise<boolean> {
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<any>) {
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<bigint> {
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;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would we want to also handle Unauthorized errors?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thinking on this, we could use some ep on constructor (like an echo get or smth simple) to check that credentials are working or else throw on constructor, wdyt?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a nice idea. I'm generally reluctant about async-ing constructors but I think that by using a static method we could do something like:

// BlockmetaJsonBlockNumberProvider.ts
async static initialize(config, ...) {
  const provider = new BlockmetaJsonBlockNumberProvider();
  const successfulConnection = await provider.testConnection();

  if (successfulConnection) return provider;
  else throw new Error("BANG");
}

Wdyt?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lol we are aligned without even trying


// 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<bigint> {
const isoTimestamp = date.toISOString();

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we validate isoTimestamp here?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ended up passing around Date instances and each method is in charge of calling the toISOString() method on it. No need for validating now 🪄

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<bigint> {
const isoTimestamp = date.toISOString();

const blockByTimePath = this.clientConfig.servicePaths.blockByTime;

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto

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<unknown>,
isoTimestamp: string,
): bigint {
const { data } = response;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should specify what we're returning in the function def right?

// TODO: validate with zod instead
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hehehe

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);
}
}
4 changes: 4 additions & 0 deletions packages/blocknumber/src/providers/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from "./blockNumberProvider.js";
export * from "./blockNumberProviderFactory.js";
export * from "./blockmetaJsonBlockNumberProvider.js";
export * from "./evmBlockNumberProvider.js";
9 changes: 8 additions & 1 deletion packages/blocknumber/src/services/blockNumberService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -20,6 +21,7 @@ export class BlockNumberService {
*/
constructor(
chainRpcUrls: Map<Caip2ChainId, RpcUrl[]>,
private readonly blockmetaConfig: BlockmetaClientConfig,
private readonly logger: ILogger,
) {
this.blockNumberProviders = this.buildBlockNumberProviders(chainRpcUrls);
Expand Down Expand Up @@ -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);

Expand Down
Loading