-
Notifications
You must be signed in to change notification settings - Fork 0
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
Changes from 1 commit
51f834f
4d8c103
d16c1d5
7513a91
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from "./invalidArgument.exception"; |
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"; | ||
} | ||
} |
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"; |
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>>; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from "./evmProvider.interface"; |
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], | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 {} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from "./viemProvider.service"; |
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", () => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
}); | ||
}); | ||
}); | ||
}); |
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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. | ||
*/ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
}); | ||
} | ||
} |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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 toviem
types. Probably a better approach for interfaces, in case are needed, would be segregation, having 2 different interfaces for example,IEvmProvider
andIContractReader