diff --git a/packages/utils-evm-test/.eslintignore b/packages/utils-evm-test/.eslintignore new file mode 100644 index 000000000..db4c6d9b6 --- /dev/null +++ b/packages/utils-evm-test/.eslintignore @@ -0,0 +1,2 @@ +dist +node_modules \ No newline at end of file diff --git a/packages/utils-evm-test/.gitignore b/packages/utils-evm-test/.gitignore new file mode 100644 index 000000000..803d4166c --- /dev/null +++ b/packages/utils-evm-test/.gitignore @@ -0,0 +1,3 @@ +artifacts +cache +deployments \ No newline at end of file diff --git a/packages/utils-evm-test/.prettierignore b/packages/utils-evm-test/.prettierignore new file mode 100644 index 000000000..763301fc0 --- /dev/null +++ b/packages/utils-evm-test/.prettierignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ \ No newline at end of file diff --git a/packages/utils-evm-test/README.md b/packages/utils-evm-test/README.md new file mode 100644 index 000000000..cc0bb56e4 --- /dev/null +++ b/packages/utils-evm-test/README.md @@ -0,0 +1,11 @@ +

+ + LayerZero + +

+ +

@layerzerolabs/utils-evm-test

+ +## Development + +This package provides integration tests for `@layerzerolabs/utils-evm` using `hardhat`. diff --git a/packages/utils-evm-test/contracts/Thrower.sol b/packages/utils-evm-test/contracts/Thrower.sol new file mode 100644 index 000000000..aa5420006 --- /dev/null +++ b/packages/utils-evm-test/contracts/Thrower.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.19; + +contract Thrower { + error CustomErrorWithNoArguments(); + error CustomErrorWithAnArgument(string message); + + function throwWithAssert() external pure { + assert(0 == 1); + } + + // For some reason in hardhat node this function does not revert + function throwWithRevertAndNoArguments() external pure { + revert(); + } + + function throwWithRevertAndArgument(string calldata message) external pure { + revert(message); + } + + // For some reason in hardhat node this function does not revert + function throwWithRequireAndNoArguments() external pure { + require(0 == 1); + } + + function throwWithRequireAndArgument(string calldata message) external pure { + require(0 == 1, message); + } + + function throwWithCustomErrorAndNoArguments() external pure { + revert CustomErrorWithNoArguments(); + } + + function throwWithCustomErrorAndArgument(string calldata message) external pure { + revert CustomErrorWithAnArgument(message); + } +} diff --git a/packages/utils-evm-test/hardhat.config.ts b/packages/utils-evm-test/hardhat.config.ts new file mode 100644 index 000000000..14f906fcc --- /dev/null +++ b/packages/utils-evm-test/hardhat.config.ts @@ -0,0 +1,10 @@ +import '@nomiclabs/hardhat-ethers' +import { HardhatUserConfig } from 'hardhat/types' + +const config: HardhatUserConfig = { + solidity: { + version: '0.8.19', + }, +} + +export default config diff --git a/packages/utils-evm-test/package.json b/packages/utils-evm-test/package.json new file mode 100644 index 000000000..e4ed335f6 --- /dev/null +++ b/packages/utils-evm-test/package.json @@ -0,0 +1,35 @@ +{ + "name": "@layerzerolabs/utils-evm-test", + "version": "0.0.1", + "private": true, + "description": "Integration tests for ua-utils-evm-hardhat for V2", + "repository": { + "type": "git", + "url": "git+https://github.com/LayerZero-Labs/lz-utils.git", + "directory": "packages/utils-evm-test" + }, + "license": "MIT", + "scripts": { + "lint": "npx eslint '**/*.{js,ts,json}'", + "test": "npx hardhat test" + }, + "devDependencies": { + "@ethersproject/abi": "^5.7.0", + "@ethersproject/abstract-provider": "^5.7.0", + "@ethersproject/abstract-signer": "^5.7.0", + "@ethersproject/contracts": "^5.7.0", + "@ethersproject/providers": "^5.7.0", + "@layerzerolabs/lz-definitions": "~1.5.68", + "@layerzerolabs/test-utils": "~0.0.1", + "@layerzerolabs/utils": "~0.0.1", + "@layerzerolabs/utils-evm": "~0.0.1", + "@nomiclabs/hardhat-ethers": "^2.2.3", + "@types/mocha": "^10.0.6", + "chai": "^4.3.10", + "ethers": "^5.7.0", + "fast-check": "^3.14.0", + "hardhat": "^2.19.0", + "ts-node": "^10.9.1", + "typescript": "^5.2.2" + } +} \ No newline at end of file diff --git a/packages/utils-evm-test/test/errors/parser.test.ts b/packages/utils-evm-test/test/errors/parser.test.ts new file mode 100644 index 000000000..33a2fbec8 --- /dev/null +++ b/packages/utils-evm-test/test/errors/parser.test.ts @@ -0,0 +1,196 @@ +import fc from 'fast-check' +import hre from 'hardhat' +import { expect } from 'chai' +import { Contract } from '@ethersproject/contracts' +import { + createErrorParser, + PanicError, + RevertError, + CustomError, + UnknownError, + OmniContractFactory, +} from '@layerzerolabs/utils-evm' +import { OmniError } from '@layerzerolabs/utils' +import { pointArbitrary } from '@layerzerolabs/test-utils' + +describe('errors/parser', () => { + describe('createErrorParser', () => { + const CONTRACT_NAME = 'Thrower' + + let contract: Contract + let omniContractFactory: OmniContractFactory + + /** + * Helper utility that swaps the promise resolution for rejection and other way around + * + * This is useful for the below tests since we are testing that promises reject + * and want to get their rejection values. + * + * @param promise `Promise` + * + * @returns `Promise` + */ + const assertFailed = async (promise: Promise): Promise => + promise.then( + (result) => { + expect.fail(`Expected a promise to always reject but it resolved with ${JSON.stringify(result)}`) + }, + (error) => error + ) + + before(async () => { + const contractFactory = await hre.ethers.getContractFactory(CONTRACT_NAME) + + contract = await contractFactory.deploy() + omniContractFactory = async ({ eid, address }) => ({ eid, contract: contractFactory.attach(address) }) + }) + + it('should pass an error through if it already is a ContractError', async () => { + const errorParser = createErrorParser(omniContractFactory) + + await fc.assert( + fc.asyncProperty(pointArbitrary, async (point) => { + const omniError: OmniError = { error: new RevertError('A reason is worth a million bytes'), point } + const parsedError = await errorParser(omniError) + + expect(parsedError.point).to.eql(point) + expect(parsedError.error).to.be.instanceOf(RevertError) + expect(parsedError.error.reason).to.equal('A reason is worth a million bytes') + }) + ) + }) + + it('should parse assert/panic', async () => { + const errorParser = createErrorParser(omniContractFactory) + + await fc.assert( + fc.asyncProperty(pointArbitrary, async (point) => { + const error = await assertFailed(contract.throwWithAssert()) + const omniError: OmniError = { error, point } + const parsedError = await errorParser(omniError) + + expect(parsedError.point).to.eql(point) + expect(parsedError.error).to.be.instanceOf(PanicError) + expect(parsedError.error.reason).to.eql(BigInt(1)) + }) + ) + }) + + it('should parse revert with arguments', async () => { + const errorParser = createErrorParser(omniContractFactory) + + await fc.assert( + fc.asyncProperty(pointArbitrary, async (point) => { + const error = await assertFailed(contract.throwWithRevertAndArgument('my bad')) + const omniError: OmniError = { error, point } + const parsedError = await errorParser(omniError) + + expect(parsedError.point).to.eql(point) + expect(parsedError.error).to.be.instanceOf(RevertError) + expect(parsedError.error.reason).to.eql('my bad') + }) + ) + }) + + it('should parse require with an argument', async () => { + const errorParser = createErrorParser(omniContractFactory) + + await fc.assert( + fc.asyncProperty(pointArbitrary, async (point) => { + const error = await assertFailed(contract.throwWithRequireAndArgument('my bad')) + const omniError: OmniError = { error, point } + const parsedError = await errorParser(omniError) + + expect(parsedError.point).to.eql(point) + expect(parsedError.error).to.be.instanceOf(RevertError) + expect(parsedError.error.reason).to.eql('my bad') + }) + ) + }) + + it('should parse require with a custom error with no arguments', async () => { + const errorParser = createErrorParser(omniContractFactory) + + await fc.assert( + fc.asyncProperty(pointArbitrary, async (point) => { + const error = await assertFailed(contract.throwWithCustomErrorAndNoArguments()) + const omniError: OmniError = { error, point } + const parsedError = await errorParser(omniError) + + expect(parsedError.point).to.eql(point) + expect(parsedError.error).to.be.instanceOf(CustomError) + expect(parsedError.error.reason).to.eql('CustomErrorWithNoArguments') + expect((parsedError.error as CustomError).args).to.eql([]) + }) + ) + }) + + it('should parse require with a custom error with an argument', async () => { + const errorParser = createErrorParser(omniContractFactory) + + await fc.assert( + fc.asyncProperty(pointArbitrary, async (point) => { + const error = await assertFailed(contract.throwWithCustomErrorAndArgument('my bad')) + const omniError: OmniError = { error, point } + const parsedError = await errorParser(omniError) + + expect(parsedError.point).to.eql(point) + expect(parsedError.error).to.be.instanceOf(CustomError) + expect(parsedError.error.reason).to.eql('CustomErrorWithAnArgument') + expect((parsedError.error as CustomError).args).to.eql(['my bad']) + }) + ) + }) + + it('should parse string', async () => { + const errorParser = createErrorParser(omniContractFactory) + + await fc.assert( + fc.asyncProperty(pointArbitrary, async (point) => { + const omniError: OmniError = { error: 'some weird error', point } + const parsedError = await errorParser(omniError) + + expect(parsedError.point).to.eql(point) + expect(parsedError.error).to.be.instanceOf(UnknownError) + expect(parsedError.error.reason).to.be.undefined + expect(parsedError.error.message).to.eql('Unknown error: some weird error') + }) + ) + }) + + it('should parse an Error', async () => { + const errorParser = createErrorParser(omniContractFactory) + + await fc.assert( + fc.asyncProperty(pointArbitrary, async (point) => { + const omniError: OmniError = { error: new Error('some weird error'), point } + const parsedError = await errorParser(omniError) + + expect(parsedError.point).to.eql(point) + expect(parsedError.error).to.be.instanceOf(UnknownError) + expect(parsedError.error.reason).to.be.undefined + expect(parsedError.error.message).to.eql('Unknown error: Error: some weird error') + }) + ) + }) + + it('should never reject', async () => { + const errorParser = createErrorParser(omniContractFactory) + + await fc.assert( + fc.asyncProperty(pointArbitrary, fc.anything(), async (point, error) => { + const omniError: OmniError = { error, point } + const parsedError = await errorParser(omniError) + + expect(parsedError.point).to.eql(point) + expect(parsedError.error).to.be.instanceOf(UnknownError) + expect(parsedError.error.reason).to.be.undefined + expect(parsedError.error.message).to.eql(`Unknown error: ${error}`) + }) + ) + }) + + // FIXME Write tests for throwWithRevertAndNoArguments - in hardhat node they don't seem to revert + // FIXME Write tests for throwWithRequireAndNoArguments - in hardhat node they don't seem to revert + }) +}) diff --git a/packages/utils-evm-test/tsconfig.json b/packages/utils-evm-test/tsconfig.json new file mode 100644 index 000000000..1e471dfdd --- /dev/null +++ b/packages/utils-evm-test/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "exclude": ["dist", "node_modules"], + "include": ["src", "test", "*.config.ts"], + "compilerOptions": { + "module": "commonjs", + "types": ["node", "mocha"] + } +} diff --git a/packages/utils-evm/package.json b/packages/utils-evm/package.json index a3dc66e25..8d8fa3edc 100644 --- a/packages/utils-evm/package.json +++ b/packages/utils-evm/package.json @@ -39,6 +39,7 @@ "p-memoize": "~4.0.1" }, "devDependencies": { + "@ethersproject/abi": "^5.7.0", "@ethersproject/abstract-provider": "^5.7.0", "@ethersproject/abstract-signer": "^5.7.0", "@ethersproject/bignumber": "^5.7.0", @@ -46,7 +47,6 @@ "@ethersproject/providers": "^5.7.0", "@layerzerolabs/lz-definitions": "~1.5.68", "@layerzerolabs/test-utils": "~0.0.1", - "@layerzerolabs/ua-utils": "~0.1.0", "@layerzerolabs/utils": "~0.0.1", "@types/jest": "^29.5.10", "fast-check": "^3.14.0", @@ -58,12 +58,12 @@ "zod": "^3.22.4" }, "peerDependencies": { + "@ethersproject/abi": "^5.7.0", "@ethersproject/abstract-provider": "^5.7.0", "@ethersproject/abstract-signer": "^5.7.0", "@ethersproject/contracts": "^5.7.0", "@ethersproject/providers": "^5.7.0", "@layerzerolabs/lz-definitions": "~1.5.68", - "@layerzerolabs/ua-utils": "~0.1.0", "@layerzerolabs/utils": "~0.0.1", "zod": "^3.22.4" } diff --git a/packages/utils-evm/src/errors/errors.ts b/packages/utils-evm/src/errors/errors.ts new file mode 100644 index 000000000..2efb6a479 --- /dev/null +++ b/packages/utils-evm/src/errors/errors.ts @@ -0,0 +1,39 @@ +export abstract class ContractError extends Error { + public abstract readonly reason: TReason +} + +export class UnknownError extends ContractError { + public readonly reason = undefined + + constructor(message = 'Unknown contract error') { + super(message) + } +} + +export class PanicError extends ContractError { + constructor( + public readonly reason: bigint, + message: string = `Contract panicked with code ${reason}` + ) { + super(message) + } +} + +export class RevertError extends ContractError { + constructor( + public readonly reason: string, + message: string = `Contract reverted with reason '${reason}'` + ) { + super(message) + } +} + +export class CustomError extends ContractError { + constructor( + public readonly reason: string, + public readonly args: unknown[], + message: string = `Contract reverted with custom error '${reason}'` + ) { + super(message) + } +} diff --git a/packages/utils-evm/src/errors/index.ts b/packages/utils-evm/src/errors/index.ts new file mode 100644 index 000000000..f73769133 --- /dev/null +++ b/packages/utils-evm/src/errors/index.ts @@ -0,0 +1,2 @@ +export * from './errors' +export * from './parser' diff --git a/packages/utils-evm/src/errors/parser.ts b/packages/utils-evm/src/errors/parser.ts new file mode 100644 index 000000000..7be417ee7 --- /dev/null +++ b/packages/utils-evm/src/errors/parser.ts @@ -0,0 +1,124 @@ +import { defaultAbiCoder } from '@ethersproject/abi' +import type { OmniContract, OmniContractFactory } from '@/omnigraph/types' +import type { OmniError } from '@layerzerolabs/utils' +import { ContractError, CustomError, UnknownError, PanicError, RevertError } from './errors' +import { BigNumberishBigintSchema } from '../schema' + +/** + * Creates an asynchronous error parser for EVM contract errors. + * + * This parser is capable of turning `unknown` `OmniError` instances to typed ones + * + * @param contractFactory `OmniContractFactory` + * + * @returns `(omniError: OmniError): Promise>` `OmniError` parser + */ +export const createErrorParser = + (contractFactory: OmniContractFactory) => + async ({ error, point }: OmniError): Promise> => { + try { + // If the error already is a ContractError, we'll continue + if (error instanceof ContractError) return { error, point } + + // If the error is unknown we'll try to decode basic errors + const candidates = getErrorDataCandidates(error) + const [basicError] = candidates.flatMap(basicDecoder) + if (basicError != null) return { point, error: basicError } + + // Then we'll try to decode custom errors + const contract = await contractFactory(point) + const contractDecoder = createContractDecoder(contract) + const [customError] = candidates.flatMap(contractDecoder) + if (customError != null) return { point, error: customError } + + // If none of the decoding works, we'll send a generic error back + return { point, error: new UnknownError(`Unknown error: ${error}`) } + } catch { + // If we fail, we send an unknown error back + return { + point, + error: new UnknownError(`Unexpected error: ${error}`), + } + } + } + +// If a contract reverts using revert, the error data will be prefixed with this beauty +const REVERT_ERROR_PREFIX = '0x08c379a0' + +// If a contract reverts with assert, the error data will be prefixed with this beauty +const PANIC_ERROR_PREFIX = '0x4e487b71' + +/** + * Basic decoder can decode a set of common errors without having access to contract ABIs + * + * @param data `string` Error revert data + * + * @returns `ContractError[]` Decoded errors, if any + */ +const basicDecoder = (data: string): ContractError[] => { + if (data === '' || data === '0x') return [new UnknownError(`Reverted with empty data`)] + + // This covers the case for assert() + if (data.startsWith(PANIC_ERROR_PREFIX)) { + const reason = data.slice(PANIC_ERROR_PREFIX.length) + + try { + const [decodedRawReason] = defaultAbiCoder.decode(['uint256'], `0x${reason}`) + const decodedReason = BigNumberishBigintSchema.parse(decodedRawReason) + + return [new PanicError(decodedReason)] + } catch { + return [new PanicError(BigInt(-1), `Reason unknown, ABI decoding failed. The raw reason was '0x${reason}'`)] + } + } + + // This covers the case for revert() and reject() + if (data.startsWith(REVERT_ERROR_PREFIX)) { + const reason = data.slice(REVERT_ERROR_PREFIX.length) + + try { + const [decodedReason] = defaultAbiCoder.decode(['string'], `0x${reason}`) + + return [new RevertError(decodedReason)] + } catch { + return [new RevertError(`Reason unknown, ABI decoding failed. The raw reason was '0x${reason}'`)] + } + } + + return [] +} + +/** + * Contract decoder uses the contract ABIs to decode error revert string + * + * @param contract `OmniContract` + * + * @returns `(data: string) => ContractError[]` Custom error decoder + */ +const createContractDecoder = + ({ contract }: OmniContract) => + (data: string): ContractError[] => { + try { + const errorDescription = contract.interface.parseError(data) + + return [new CustomError(errorDescription.name, [...errorDescription.args])] + } catch { + return [] + } + } + +/** + * Helper function that traverses an unknown error and agthers all the fields + * that could possibly contain the revert data. + * + * The results are order from the most specific one to the least specific one + * since the function above will prioritize the results in front of the list + * + * @param error `unknown` + * + * @returns `string[]` A list of possible error revert strings + */ +const getErrorDataCandidates = (error: unknown): string[] => + [(error as any)?.error?.data?.data, (error as any)?.error?.data, (error as any)?.data].filter( + (candidate: unknown) => typeof candidate === 'string' + ) diff --git a/packages/utils-evm/src/index.ts b/packages/utils-evm/src/index.ts index e922e191b..f20ce5279 100644 --- a/packages/utils-evm/src/index.ts +++ b/packages/utils-evm/src/index.ts @@ -1,4 +1,5 @@ export * from './address' +export * from './errors' export * from './omnigraph' export * from './provider' export * from './schema' diff --git a/packages/utils/src/omnigraph/types.ts b/packages/utils/src/omnigraph/types.ts index 860202f8d..089a96c65 100644 --- a/packages/utils/src/omnigraph/types.ts +++ b/packages/utils/src/omnigraph/types.ts @@ -21,6 +21,14 @@ export interface OmniVector { to: OmniPoint } +/** + * OmniError represents an arbitrary error that occurred on a particular point in omniverse. + */ +export interface OmniError { + point: OmniPoint + error: TError +} + /** * OmniNode represents a point in omniverse * with an additional piece of information attached