Skip to content

Commit

Permalink
feat: implements BlockNumberService (#13)
Browse files Browse the repository at this point in the history
# 🤖 Linear

Closes GRT-38

## Description

- Refactors the `CAIP2` chain id class, using it as a static module that
only validates a string being compliant.
- Implements `BlockNumberService`, enabling the agent to get from
multiple chains their block numbers at a particular timestamp.
  • Loading branch information
0xyaco authored Aug 2, 2024
1 parent d50feae commit 995a84a
Show file tree
Hide file tree
Showing 23 changed files with 526 additions and 122 deletions.
9 changes: 9 additions & 0 deletions packages/blocknumber/src/exceptions/chainWithoutProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Caip2ChainId } from "../types.js";

export class ChainWithoutProvider extends Error {
constructor(chainId: Caip2ChainId) {
super(`Chain ${chainId} has no provider defined.`);

this.name = "ChainWithoutProvider";
}
}
7 changes: 7 additions & 0 deletions packages/blocknumber/src/exceptions/emptyRpcUrls.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export class EmptyRpcUrls extends Error {
constructor() {
super(`At least one chain with its RPC endpoint must be defined.`);

this.name = "EmptyRpcUrls";
}
}
3 changes: 3 additions & 0 deletions packages/blocknumber/src/exceptions/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
export * from "./chainWithoutProvider.js";
export * from "./emptyRpcUrls.js";
export * from "./invalidChain.js";
export * from "./invalidTimestamp.js";
export * from "./lastBlockEpoch.js";
export * from "./timestampNotFound.js";
export * from "./unexpectedSearchRange.js";
export * from "./unsupportedBlockNumber.js";
export * from "./unsupportedBlockTimestamps.js";
export * from "./unsupportedChain.js";
9 changes: 9 additions & 0 deletions packages/blocknumber/src/exceptions/unsupportedChain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Caip2ChainId } from "../types.js";

export class UnsupportedChain extends Error {
constructor(chainId: Caip2ChainId) {
super(`Chain ${chainId} is not supported.`);

this.name = "UnsupportedChain";
}
}
11 changes: 0 additions & 11 deletions packages/blocknumber/src/helloWorld.spec.ts

This file was deleted.

3 changes: 0 additions & 3 deletions packages/blocknumber/src/helloWorld.ts

This file was deleted.

38 changes: 38 additions & 0 deletions packages/blocknumber/src/providers/blockNumberProviderFactory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { EBO_SUPPORTED_CHAINS_CONFIG, ILogger } from "@ebo-agent/shared";
import { FallbackTransport, HttpTransport, PublicClient } from "viem";

import { UnsupportedChain } from "../exceptions/unsupportedChain.js";
import { Caip2ChainId } from "../types.js";
import { Caip2Utils } from "../utils/index.js";
import { EvmBlockNumberProvider } from "./evmBlockNumberProvider.js";

const DEFAULT_PROVIDER_CONFIG = {
blocksLookback: 10_000n,
deltaMultiplier: 2n,
};

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 logger a ILogger instance
* @returns
*/
public static buildProvider(
chainId: Caip2ChainId,
client: PublicClient<FallbackTransport<HttpTransport[]>>,
logger: ILogger,
) {
const chainNamespace = Caip2Utils.getNamespace(chainId);

switch (chainNamespace) {
case EBO_SUPPORTED_CHAINS_CONFIG.evm.namespace:
return new EvmBlockNumberProvider(client, DEFAULT_PROVIDER_CONFIG, logger);

default:
throw new UnsupportedChain(chainId);
}
}
}
13 changes: 6 additions & 7 deletions packages/blocknumber/src/providers/evmBlockNumberProvider.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ILogger } from "@ebo-agent/shared";
import { Block, PublicClient } from "viem";
import { Block, FallbackTransport, HttpTransport, PublicClient } from "viem";

import {
InvalidTimestamp,
Expand All @@ -9,7 +9,6 @@ import {
UnsupportedBlockNumber,
UnsupportedBlockTimestamps,
} from "../exceptions/index.js";
import logger from "../utils/logger.js";
import { BlockNumberProvider } from "./blockNumberProvider.js";

const BINARY_SEARCH_BLOCKS_LOOKBACK = 10_000n;
Expand All @@ -31,7 +30,7 @@ interface SearchConfig {
}

export class EvmBlockNumberProvider implements BlockNumberProvider {
private client: PublicClient;
private client: PublicClient<FallbackTransport<HttpTransport[]>>;
private searchConfig: SearchConfig;
private firstBlock: Block | null;

Expand All @@ -45,7 +44,7 @@ export class EvmBlockNumberProvider implements BlockNumberProvider {
* while scanning blocks backwards during lower bound search. Defaults to 2.
*/
constructor(
client: PublicClient,
client: PublicClient<FallbackTransport<HttpTransport[]>>,
searchConfig: { blocksLookback?: bigint; deltaMultiplier?: bigint },
private logger: ILogger,
) {
Expand Down Expand Up @@ -201,15 +200,15 @@ export class EvmBlockNumberProvider implements BlockNumberProvider {

if (low > high) throw new UnexpectedSearchRange(low, high);

logger.debug(`Starting block binary search for timestamp ${timestamp}...`);
this.logger.debug(`Starting block binary search for timestamp ${timestamp}...`);

while (low <= high) {
currentBlockNumber = (high + low) / 2n;

const currentBlock = await this.client.getBlock({ blockNumber: currentBlockNumber });
const nextBlock = await this.client.getBlock({ blockNumber: currentBlockNumber + 1n });

logger.debug(
this.logger.debug(
`Analyzing block number #${currentBlock.number} with timestamp ${currentBlock.timestamp}`,
);

Expand All @@ -223,7 +222,7 @@ export class EvmBlockNumberProvider implements BlockNumberProvider {
currentBlock.timestamp <= timestamp && nextBlock.timestamp > timestamp;

if (blockContainsTimestamp) {
logger.debug(`Block #${currentBlock.number} contains timestamp.`);
this.logger.debug(`Block #${currentBlock.number} contains timestamp.`);

return currentBlock.number;
} else if (currentBlock.timestamp <= timestamp) {
Expand Down
103 changes: 103 additions & 0 deletions packages/blocknumber/src/services/blockNumberService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { EBO_SUPPORTED_CHAIN_IDS, ILogger } from "@ebo-agent/shared";
import { createPublicClient, fallback, http } from "viem";

import { ChainWithoutProvider, EmptyRpcUrls, UnsupportedChain } from "../exceptions/index.js";
import { BlockNumberProvider } from "../providers/blockNumberProvider.js";
import { BlockNumberProviderFactory } from "../providers/blockNumberProviderFactory.js";
import { Caip2ChainId } from "../types.js";

type RpcUrl = NonNullable<Parameters<typeof http>[0]>;

export class BlockNumberService {
private blockNumberProviders: Map<Caip2ChainId, BlockNumberProvider>;

/**
* Create a `BlockNumberService` instance that will handle the interaction with a collection
* of chains.
*
* @param chainRpcUrls a map of CAIP-2 chain ids with their RPC urls that this service will handle
* @param logger a `ILogger` instance
*/
constructor(
chainRpcUrls: Map<Caip2ChainId, RpcUrl[]>,
private readonly logger: ILogger,
) {
this.blockNumberProviders = this.buildBlockNumberProviders(chainRpcUrls);
}

/**
* Get a chain epoch block number based on a timestamp.
*
* @param timestamp UTC timestamp in ms since UNIX epoch
* @param chainId the CAIP-2 chain id
* @returns the block number corresponding to the timestamp
*/
public async getEpochBlockNumber(timestamp: number, chainId: Caip2ChainId): Promise<bigint> {
const provider = this.blockNumberProviders.get(chainId);

if (!provider) throw new ChainWithoutProvider(chainId);

const blockNumber = await provider.getEpochBlockNumber(timestamp);

return blockNumber;
}

/**
* Get the epoch block number for all the specified chains based on a timestamp.
*
* @param timestamp UTC timestamp in ms since UNIX epoch
* @param chains a list of CAIP-2 chain ids
* @returns a map of CAIP-2 chain ids
*/
public async getEpochBlockNumbers(timestamp: number, chains: Caip2ChainId[]) {
const epochBlockNumbers = await Promise.all(
chains.map(async (chain) => ({
chainId: chain,
blockNumber: await this.getEpochBlockNumber(timestamp, chain),
})),
);

return epochBlockNumbers.reduce((epochBlockNumbersMap, epoch) => {
return epochBlockNumbersMap.set(epoch.chainId, epoch.blockNumber);
}, new Map<Caip2ChainId, bigint>());
}

/**
* Build a collection of `BlockNumberProvider`s instances respective to each
* CAIP-2 chain id.
*
* @param chainRpcUrls a map containing chain ids with their respective list of RPC urls
* @returns a map of CAIP-2 chain ids and their respective `BlockNumberProvider` instances
*/
private buildBlockNumberProviders(chainRpcUrls: Map<Caip2ChainId, RpcUrl[]>) {
if (chainRpcUrls.size == 0) throw new EmptyRpcUrls();

const providers = new Map<Caip2ChainId, BlockNumberProvider>();

for (const [chainId, urls] of chainRpcUrls) {
if (!this.isChainSupported(chainId)) throw new UnsupportedChain(chainId);

const client = createPublicClient({
transport: fallback(urls.map((url) => http(url))),
});

const provider = BlockNumberProviderFactory.buildProvider(chainId, client, this.logger);

if (!provider) throw new ChainWithoutProvider(chainId);

providers.set(chainId, provider);
}

return providers;
}

/**
* Check if a chain is supported by the service.
*
* @param chainId CAIP-2 chain id
* @returns true if the chain is supported, false otherwise
*/
private isChainSupported(chainId: Caip2ChainId) {
return EBO_SUPPORTED_CHAIN_IDS.includes(chainId);
}
}
1 change: 1 addition & 0 deletions packages/blocknumber/src/services/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./blockNumberService.js";
1 change: 1 addition & 0 deletions packages/blocknumber/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type Caip2ChainId = `${string}:${string}`;
Original file line number Diff line number Diff line change
@@ -1,41 +1,19 @@
// Based on https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-2.md

import { InvalidChainId } from "../exceptions/invalidChain.js";

type ChainNamespace = string;
type ChainReference = string;

interface ChainIdParams {
namespace: ChainNamespace;
reference: ChainReference;
}
import { InvalidChainId } from "../../exceptions/invalidChain.js";
import { Caip2ChainId } from "../../types.js";

const NAMESPACE_FORMAT = /^[-a-z0-9]{3,8}$/;
const REFERENCE_FORMAT = /^[-_a-zA-Z0-9]{1,32}$/;

export class ChainId {
private namespace: string;
private reference: string;

/**
* Creates a validated CAIP-2 compliant chain ID.
*
* @param chainId a CAIP-2 compliant string.
*/
constructor(chainId: string) {
const params = ChainId.parse(chainId);

this.namespace = params.namespace;
this.reference = params.reference;
}

export class Caip2Utils {
/**
* Parses a CAIP-2 compliant string.
*
* @param chainId {string} a CAIP-2 compliant string
* @returns an object containing the namespace and the reference of the chain id
* @returns the CAIP-2 validated chain id string
*/
public static parse(chainId: string): ChainIdParams {
public static validateChainId(chainId: string): chainId is Caip2ChainId {
const elements = chainId.split(":");

if (elements.length !== 2) {
Expand All @@ -54,13 +32,14 @@ export class ChainId {
const isValidReference = REFERENCE_FORMAT.test(reference);
if (!isValidReference) throw new InvalidChainId("Chain ID reference is not valid.");

return {
namespace,
reference,
};
return true;
}

public toString() {
return `${this.namespace}:${this.reference}`;
public static getNamespace(chainId: string | Caip2ChainId) {
this.validateChainId(chainId);

const namespace = chainId.split(":")[0] as string;

return namespace;
}
}
1 change: 1 addition & 0 deletions packages/blocknumber/src/utils/caip/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./caip2Utils.js";
3 changes: 1 addition & 2 deletions packages/blocknumber/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
export * from "./chainId.js";
export * from "./logger.js";
export * from "./caip/caip2Utils.js";
15 changes: 0 additions & 15 deletions packages/blocknumber/src/utils/logger.ts

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Logger } from "@ebo-agent/shared";
import { createPublicClient, fallback, http } 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";

describe("BlockNumberProviderFactory", () => {
const logger = Logger.getInstance();

describe("buildProvider", () => {
const client = createPublicClient({ transport: fallback([http("http://localhost:8545")]) });

it("builds a provider", () => {
const provider = BlockNumberProviderFactory.buildProvider("eip155:1", client, logger);

expect(provider).toBeInstanceOf(EvmBlockNumberProvider);
});

it("fails if chain is not supported", () => {
const unsupportedChainId = "solana:80085" as Caip2ChainId;

expect(() => {
BlockNumberProviderFactory.buildProvider(unsupportedChainId, client, logger);
}).toThrow(UnsupportedChain);
});
});
});
Loading

0 comments on commit 995a84a

Please sign in to comment.