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: viem wrapper with readContract and base methods #25

Merged
merged 4 commits into from
Jul 19, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions libs/providers/src/exceptions/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./invalidArgument.exception";
6 changes: 6 additions & 0 deletions libs/providers/src/exceptions/invalidArgument.exception.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export class InvalidArgumentException extends Error {
constructor(message: string) {
super(message);
this.name = "InvalidArgumentException";
}
}
2 changes: 2 additions & 0 deletions libs/providers/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
export * from "./interfaces";
export * from "./providers";
export * from "./providers.module";
export * from "./evmProvider.service";
66 changes: 66 additions & 0 deletions libs/providers/src/interfaces/evmProvider.interface.ts
Copy link
Collaborator

Choose a reason for hiding this comment

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

IEvmProvider interface is not needed imo, given that we are not going to have many implementations of EvmProvider

Copy link
Collaborator

Choose a reason for hiding this comment

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

Also notice that readContract() has a very specific signature coupled to viem types. Probably a better approach for interfaces, in case are needed, would be segregation, having 2 different interfaces for example, IEvmProvider and IContractReader

Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import {
Abi,
Address,
ContractFunctionArgs,
ContractFunctionName,
ContractFunctionReturnType,
Hex,
} from "viem";

/**
* Represents the interface for an Ethereum Virtual Machine (EVM) provider.
*/
export interface IEvmProvider {
/**
* Retrieves the balance of the specified address.
* @param {Address} address The address for which to retrieve the balance.
* @returns {Promise<bigint>} A Promise that resolves to the balance of the address.
*/
getBalance(address: Address): Promise<bigint>;

/**
* Retrieves the current block number.
* @returns {Promise<bigint>} A Promise that resolves to the latest block number.
*/
getBlockNumber(): Promise<bigint>;

/**
* Retrieves the current estimated gas price on the chain.
* @returns {Promise<bigint>} A Promise that resolves to the current gas price.
*/
getGasPrice(): Promise<bigint>;

/**
* Retrieves the value from a storage slot at a given address.
* @param {Address} address The address of the contract.
* @param {number} slot The slot number to read.
* @returns {Promise<Hex>} A Promise that resolves to the value of the storage slot.
*/
getStorageAt(address: Address, slot: number): Promise<Hex | undefined>;

/**
* Type safe way to read a contract's "view" | "pure" functions.
* @param {Address} contractAddress The address of the contract.
* @param {TAbi} abi The contract's ABI (Application Binary Interface).
* @param {TFunctionName} functionName The name of the function to invoke.
* @param {TArgs} args Optional arguments to pass to the function.
* @returns A Promise that resolves to the return value of the contract function.
*/
readContract<
TAbi extends Abi,
TFunctionName extends ContractFunctionName<TAbi, "pure" | "view"> = ContractFunctionName<
TAbi,
"pure" | "view"
>,
TArgs extends ContractFunctionArgs<
TAbi,
"pure" | "view",
TFunctionName
> = ContractFunctionArgs<TAbi, "pure" | "view", TFunctionName>,
>(
contractAddress: Address,
abi: TAbi,
functionName: TFunctionName,
args?: TArgs,
): Promise<ContractFunctionReturnType<TAbi, "pure" | "view", TFunctionName, TArgs>>;
}
1 change: 1 addition & 0 deletions libs/providers/src/interfaces/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./evmProvider.interface";
5 changes: 3 additions & 2 deletions libs/providers/src/providers.module.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { Module } from "@nestjs/common";

import { EvmProviderService } from "./evmProvider.service";
import { ViemProviderService } from "./providers";

/**
* Module for managing provider services.
* This module exports Services for interacting with EVM-based blockchains.
*/
@Module({
providers: [EvmProviderService],
exports: [EvmProviderService],
providers: [EvmProviderService, ViemProviderService],
exports: [EvmProviderService, ViemProviderService],
Copy link
Collaborator

Choose a reason for hiding this comment

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

we can get rid of EvmProviderService. Here should be just 1 providers item and 1 exports item

})
export class ProvidersModule {}
1 change: 1 addition & 0 deletions libs/providers/src/providers/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./viemProvider.service";
150 changes: 150 additions & 0 deletions libs/providers/src/providers/viemProvider.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { createMock } from "@golevelup/ts-jest";
import { Test, TestingModule } from "@nestjs/testing";
import { parseAbi } from "abitype";
import * as viem from "viem";
import { localhost } from "viem/chains";

import { ViemProviderService } from "./viemProvider.service";

const mockClient = createMock<ReturnType<typeof viem.createPublicClient>>();

jest.mock("viem", () => ({
...jest.requireActual("viem"),
createPublicClient: jest.fn().mockImplementation(() => mockClient),
http: jest.fn(),
}));

describe("ViemProviderService", () => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

rename here also

let viemProvider: ViemProviderService;
const testAbi = parseAbi([
"function balanceOf(address owner) view returns (uint256)",
"function tokenURI(uint256 tokenId) pure returns (string)",
]);

beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
providers: [
{
provide: ViemProviderService,
useFactory: () => {
const rpcUrl = "http://localhost:8545";
const chain = localhost;
return new ViemProviderService(rpcUrl, chain);
},
},
],
}).compile();

viemProvider = app.get<ViemProviderService>(ViemProviderService);
});

afterEach(() => {
jest.clearAllMocks();
});

describe("getBalance", () => {
it("should return the balance of the specified address", async () => {
const address = "0x123456789";
const expectedBalance = 100n;
jest.spyOn(mockClient, "getBalance").mockResolvedValue(expectedBalance);

const balance = await viemProvider.getBalance(address);

expect(balance).toBe(expectedBalance);
expect(mockClient.getBalance).toHaveBeenCalledWith({ address });
});
});

describe("getBlockNumber", () => {
it("should return the current block number", async () => {
const expectedBlockNumber = 1000n;
jest.spyOn(mockClient, "getBlockNumber").mockResolvedValue(expectedBlockNumber);

const blockNumber = await viemProvider.getBlockNumber();

expect(blockNumber).toBe(expectedBlockNumber);
});
});

describe("getGasPrice", () => {
it("should return the current gas price", async () => {
const expectedGasPrice = BigInt(100);

// Mock the getGasPrice method of the Viem client
jest.spyOn(viemProvider["client"], "getGasPrice").mockResolvedValue(expectedGasPrice);

const gasPrice = await viemProvider.getGasPrice();

expect(gasPrice).toBe(expectedGasPrice);
});
});

describe("getStorageAt", () => {
it("should return the value of the storage slot at the given address and slot number", async () => {
const address = "0x123456789";
const slot = 1;
const expectedValue = "0xabcdef";
jest.spyOn(mockClient, "getStorageAt").mockResolvedValue(expectedValue);

const value = await viemProvider.getStorageAt(address, slot);

expect(value).toBe(expectedValue);
expect(mockClient.getStorageAt).toHaveBeenCalledWith({ address, slot: "0x1" });
});

it("should throw an error if the slot is not a positive integer", async () => {
const address = "0x123456789";
const slot = -1;

await expect(viemProvider.getStorageAt(address, slot)).rejects.toThrowError(
"Slot must be a positive integer number. Received: -1",
);
});
});

describe("readContract", () => {
it("should call the readContract method of the Viem client with the correct arguments", async () => {
const contractAddress = "0x123456789";
const abi = testAbi;
const functionName = "balanceOf";
const expectedReturnValue = 5n;

// Mock the readContract method of the Viem client
jest.spyOn(mockClient, "readContract").mockResolvedValue(expectedReturnValue);

const returnValue = await viemProvider.readContract(contractAddress, abi, functionName);

expect(returnValue).toBe(expectedReturnValue);
expect(mockClient.readContract).toHaveBeenCalledWith({
address: contractAddress,
abi,
functionName,
});
});

it("should call the readContract method of the Viem client with the correct arguments when args are provided", async () => {
const contractAddress = "0x123456789";
const functionName = "tokenURI";
const args = [1n] as const;
const expectedReturnValue = "tokenUri";

// Mock the readContract method of the Viem client
jest.spyOn(mockClient, "readContract").mockResolvedValue(expectedReturnValue);

const returnValue = await viemProvider.readContract(
contractAddress,
testAbi,
functionName,
args,
);

expect(returnValue).toBe(expectedReturnValue);
expect(mockClient.readContract).toHaveBeenCalledWith({
address: contractAddress,
abi: testAbi,
functionName,
args,
});
});
});
});
112 changes: 112 additions & 0 deletions libs/providers/src/providers/viemProvider.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { Injectable } from "@nestjs/common";
import { InvalidArgumentException } from "@packages/providers/exceptions";
import { IEvmProvider } from "@packages/providers/interfaces";
import {
Abi,
Address,
Chain,
ContractFunctionArgs,
ContractFunctionName,
ContractFunctionReturnType,
createPublicClient,
Hex,
http,
HttpTransport,
toHex,
} from "viem";

/**
* Acts as a wrapper around Viem library to provide methods to interact with an EVM-based blockchain.
*/
@Injectable()
export class ViemProviderService implements IEvmProvider {
Copy link
Collaborator

Choose a reason for hiding this comment

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

I would rename this ViemProviderService to EvmProviderService

private client: ReturnType<typeof createPublicClient<HttpTransport, Chain>>;

constructor(
rpcUrl: string,
readonly chain: Chain,
) {
this.client = createPublicClient({
chain,
transport: http(rpcUrl),
});
}

/**
* Retrieves the balance of the specified address.
* @param {Address} address The address for which to retrieve the balance.
* @returns {Promise<bigint>} A Promise that resolves to the balance of the address.
*/
async getBalance(address: Address): Promise<bigint> {
return this.client.getBalance({ address });
}

/**
* Retrieves the current block number.
* @returns {Promise<bigint>} A Promise that resolves to the latest block number.
*/
async getBlockNumber(): Promise<bigint> {
return this.client.getBlockNumber();
}

/**
* Retrieves the current estimated gas price on the chain.
* @returns {Promise<bigint>} A Promise that resolves to the current gas price.
*/
async getGasPrice(): Promise<bigint> {
return this.client.getGasPrice();
}

/**
* Retrieves the value from a storage slot at a given address.
* @param {Address} address The address of the contract.
* @param {number} slot The slot number to read.
* @returns {Promise<Hex>} A Promise that resolves to the value of the storage slot.
* @throws {InvalidArgumentException} If the slot is not a positive integer.
*/
Copy link
Collaborator

Choose a reason for hiding this comment

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

Just a suggestion here. When you are implementing an interface i think you can inherit the natspec from it.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

ooaa nice jajaja

async getStorageAt(address: Address, slot: number): Promise<Hex | undefined> {
if (slot <= 0 || !Number.isInteger(slot)) {
throw new InvalidArgumentException(
`Slot must be a positive integer number. Received: ${slot}`,
);
}

return this.client.getStorageAt({
address,
slot: toHex(slot),
});
}

/**
* Reads a contract "pure" or "view" function with the specified arguments using readContract from Viem.
* @param {Address} contractAddress - The address of the contract.
* @param {TAbi} abi - The ABI (Application Binary Interface) of the contract.
* @param {TFunctionName} functionName - The name of the function to call.
* @param {TArgs} [args] - The arguments to pass to the function (optional).
* @returns A promise that resolves to the return value of the contract function.
*/
async readContract<
TAbi extends Abi,
TFunctionName extends ContractFunctionName<TAbi, "pure" | "view"> = ContractFunctionName<
TAbi,
"pure" | "view"
>,
TArgs extends ContractFunctionArgs<
TAbi,
"pure" | "view",
TFunctionName
> = ContractFunctionArgs<TAbi, "pure" | "view", TFunctionName>,
>(
contractAddress: Address,
abi: TAbi,
functionName: TFunctionName,
args?: TArgs,
): Promise<ContractFunctionReturnType<TAbi, "pure" | "view", TFunctionName, TArgs>> {
return this.client.readContract({
address: contractAddress,
abi,
functionName,
args,
});
}
}
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@
"@nestjs/core": "10.0.0",
"@nestjs/platform-express": "10.0.0",
"@nestjs/swagger": "7.4.0",
"abitype": "1.0.5",
"reflect-metadata": "0.1.13",
"rxjs": "7.8.1"
"rxjs": "7.8.1",
"viem": "2.17.5"
},
"devDependencies": {
"@commitlint/config-conventional": "19.2.2",
Expand Down
Loading
Loading