Skip to content

Commit

Permalink
feat: add blockmeta block number provider (#37)
Browse files Browse the repository at this point in the history
# 🤖 Linear

Closes GRT-131

## Description
Implements the provider for consuming block meta service by using HTTP
POST requests with JSON content.

The `blockmeta.BlockByTime` service has two operations:
* `BlockByTime/At` which gives you the block that's been mined at a
specific timestamp
* `BlockByTime/Before` which gives you the most recent block that's been
mined before a specific timestamp

Given that `BlockByTime/Before` is not inclusive, some extra logic that
coordinates the two operations need to be added to get the relevant
block for EBO operation.
  • Loading branch information
0xyaco authored Sep 13, 2024
1 parent 4186fff commit 3d7c923
Show file tree
Hide file tree
Showing 12 changed files with 875 additions and 17 deletions.
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:
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";
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<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;

// 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();

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;

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;
// 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);
}
}
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

0 comments on commit 3d7c923

Please sign in to comment.