From 3f72f5293fdfdd62e5f17836c26513748dd9c08a Mon Sep 17 00:00:00 2001 From: Nazar Hussain Date: Fri, 17 Dec 2021 13:24:32 +0100 Subject: [PATCH] Implement static type interface for Contract (#4604) * Add contract static type support * Update contract typing and add tests for erc20 * Add ERC721 type tests * :art: Update docs and readme * :memo: Updated changelog * :art: Use PromiEvent implementation * :art: Fix some reference typing * :memo: Add compatibility docs * :memo: Update the readme to list a reference to a limitation * Add unit tests for some types * :art: Fix the conflicts * :art: Fix lint errors * :rotating_light: Fix linting warnings * Apply suggestions from code review Co-authored-by: Wyatt Barnes * Update code per feedback Co-authored-by: Wyatt Barnes --- CHANGELOG.md | 5 + packages/web3-common/src/promi_event.ts | 4 +- packages/web3-eth-abi/package.json | 5 +- packages/web3-eth-abi/src/api/events_api.ts | 4 +- .../web3-eth-abi/src/api/functions_api.ts | 6 +- packages/web3-eth-abi/src/api/logs_api.ts | 12 +- .../web3-eth-abi/src/api/parameters_api.ts | 6 +- packages/web3-eth-abi/src/number_map_type.ts | 34 ++ packages/web3-eth-abi/src/types.ts | 228 +++++++++-- packages/web3-eth-abi/src/utils.ts | 60 +-- packages/web3-eth-abi/test/unit/types.test.ts | 143 +++++++ packages/web3-eth-contract/README.md | 23 ++ packages/web3-eth-contract/package.json | 3 +- packages/web3-eth-contract/src/contract.ts | 57 ++- packages/web3-eth-contract/src/encoding.ts | 21 +- packages/web3-eth-contract/src/types.ts | 125 ++++++- .../web3-eth-contract/test/fixtures/erc20.ts | 316 ++++++++++++++++ .../web3-eth-contract/test/fixtures/erc721.ts | 353 ++++++++++++++++++ .../test/unit/contract_typing.test.ts | 48 +++ packages/web3-utils/src/types.ts | 41 ++ yarn.lock | 12 + 21 files changed, 1389 insertions(+), 117 deletions(-) create mode 100644 packages/web3-eth-abi/src/number_map_type.ts create mode 100644 packages/web3-eth-abi/test/unit/types.test.ts create mode 100644 packages/web3-eth-contract/test/fixtures/erc20.ts create mode 100644 packages/web3-eth-contract/test/fixtures/erc721.ts create mode 100644 packages/web3-eth-contract/test/unit/contract_typing.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bbe961eb2a..a5f8ba7c3f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -343,3 +343,8 @@ Released with 1.0.0-beta.37 code base. 2. `ignoreLength` will be removed as an optional parameter for `privateKeyToAccount` 3. The `Wallet` no more supports address/number indexing. Have to use `wallet.get` instead. 4. `Wallet.create` function doesn't accepts `entropy` param + +#### web3-eth-contract + +1. Event logs does not support types for indexed properties but named properties supported. +2. Types for overloaded ABI functions are not yet supported. diff --git a/packages/web3-common/src/promi_event.ts b/packages/web3-common/src/promi_event.ts index 5e0d5ca6adb..2eaaa9a3d66 100644 --- a/packages/web3-common/src/promi_event.ts +++ b/packages/web3-common/src/promi_event.ts @@ -5,8 +5,8 @@ export type PromiseExecutor = ( reject: (reason: unknown) => void, ) => void; -export class PromiEvent - extends Web3EventEmitter +export class PromiEvent + extends Web3EventEmitter implements Promise { private readonly _promise: Promise; diff --git a/packages/web3-eth-abi/package.json b/packages/web3-eth-abi/package.json index 6a8a0b411ba..5cea0431248 100644 --- a/packages/web3-eth-abi/package.json +++ b/packages/web3-eth-abi/package.json @@ -25,9 +25,9 @@ "test:integration": "jest --config=./test/integration/jest.config.js" }, "dependencies": { - "web3-utils": "^4.0.0-alpha.0", + "@ethersproject/abi": "^5.0.7", "web3-common": "^1.0.0-alpha.0", - "@ethersproject/abi": "^5.0.7" + "web3-utils": "^4.0.0-alpha.0" }, "devDependencies": { "@types/jest": "^27.0.3", @@ -41,6 +41,7 @@ "jest": "^27.3.1", "jest-extended": "^1.1.0", "jest-when": "^3.4.1", + "@humeris/espresso-shot": "^4.0.0", "prettier": "^2.4.1", "ts-jest": "^27.0.7", "typescript": "^4.5.2" diff --git a/packages/web3-eth-abi/src/api/events_api.ts b/packages/web3-eth-abi/src/api/events_api.ts index 17f3d1d370c..4a89cb3aacd 100644 --- a/packages/web3-eth-abi/src/api/events_api.ts +++ b/packages/web3-eth-abi/src/api/events_api.ts @@ -1,12 +1,12 @@ import { AbiError } from 'web3-common'; import { sha3Raw } from 'web3-utils'; -import { JsonAbiEventFragment } from '../types'; +import { AbiEventFragment } from '../types'; import { jsonInterfaceMethodToString, isAbiEventFragment } from '../utils'; /** * Encodes the event name to its ABI signature, which are the sha3 hash of the event name including input types.. */ -export const encodeEventSignature = (functionName: string | JsonAbiEventFragment): string => { +export const encodeEventSignature = (functionName: string | AbiEventFragment): string => { if (typeof functionName !== 'string' && !isAbiEventFragment(functionName)) { throw new AbiError('Invalid parameter value in encodeEventSignature'); } diff --git a/packages/web3-eth-abi/src/api/functions_api.ts b/packages/web3-eth-abi/src/api/functions_api.ts index 143581d6c7f..ae832fb50fc 100644 --- a/packages/web3-eth-abi/src/api/functions_api.ts +++ b/packages/web3-eth-abi/src/api/functions_api.ts @@ -1,13 +1,13 @@ import { AbiError } from 'web3-common'; import { sha3Raw } from 'web3-utils'; import { isAbiFunctionFragment, jsonInterfaceMethodToString } from '../utils'; -import { JsonAbiFunctionFragment } from '../types'; +import { AbiFunctionFragment } from '../types'; import { encodeParameters } from './parameters_api'; /** * Encodes the function name to its ABI representation, which are the first 4 bytes of the sha3 of the function name including types. */ -export const encodeFunctionSignature = (functionName: string | JsonAbiFunctionFragment): string => { +export const encodeFunctionSignature = (functionName: string | AbiFunctionFragment): string => { if (typeof functionName !== 'string' && !isAbiFunctionFragment(functionName)) { throw new AbiError('Invalid parameter value in encodeFunctionSignature'); } @@ -27,7 +27,7 @@ export const encodeFunctionSignature = (functionName: string | JsonAbiFunctionFr * Encodes a function call from its json interface and parameters. */ export const encodeFunctionCall = ( - jsonInterface: JsonAbiFunctionFragment, + jsonInterface: AbiFunctionFragment, params: unknown[], ): string => { if (!isAbiFunctionFragment(jsonInterface)) { diff --git a/packages/web3-eth-abi/src/api/logs_api.ts b/packages/web3-eth-abi/src/api/logs_api.ts index ab2d705ba65..97ad08f468c 100644 --- a/packages/web3-eth-abi/src/api/logs_api.ts +++ b/packages/web3-eth-abi/src/api/logs_api.ts @@ -1,5 +1,5 @@ import { HexString } from 'web3-utils'; -import { JsonAbiParameter } from '../types'; +import { AbiParameter } from '../types'; import { decodeParameter, decodeParametersWith } from './parameters_api'; const STATIC_TYPES = ['bool', 'string', 'int', 'uint', 'address', 'fixed', 'ufixed']; @@ -8,7 +8,7 @@ const STATIC_TYPES = ['bool', 'string', 'int', 'uint', 'address', 'fixed', 'ufix * Decodes ABI-encoded log data and indexed topic data */ export const decodeLog = >( - inputs: Array, + inputs: Array, data: HexString, topics: string | string[], ) => { @@ -16,20 +16,20 @@ export const decodeLog = >( const clonedData = data ?? ''; - const notIndexedInputs: Array = []; - const indexedParams: Array = []; + const notIndexedInputs: Array = []; + const indexedParams: Array = []; let topicCount = 0; // TODO check for anonymous logs? for (const [i, input] of inputs.entries()) { if (input.indexed) { indexedParams[i] = STATIC_TYPES.some(s => input.type.startsWith(s)) - ? (decodeParameter(input.type, clonedTopics[topicCount]) as JsonAbiParameter) + ? (decodeParameter(input.type, clonedTopics[topicCount]) as AbiParameter) : clonedTopics[topicCount]; topicCount += 1; } else { - notIndexedInputs[i] = input as unknown as JsonAbiParameter; + notIndexedInputs[i] = input as unknown as AbiParameter; } } diff --git a/packages/web3-eth-abi/src/api/parameters_api.ts b/packages/web3-eth-abi/src/api/parameters_api.ts index a6a676a2efc..49ef68baa6b 100644 --- a/packages/web3-eth-abi/src/api/parameters_api.ts +++ b/packages/web3-eth-abi/src/api/parameters_api.ts @@ -8,9 +8,11 @@ import { formatParam, isAbiFragment, mapTypes, modifyParams } from '../utils'; /** * Should be used to encode list of params */ -export const encodeParameters = (abi: AbiInput[], params: unknown[]): string => { +export const encodeParameters = (abi: ReadonlyArray, params: unknown[]): string => { try { - const modifiedTypes = mapTypes(Array.isArray(abi) ? abi : [abi]); + const modifiedTypes = mapTypes( + Array.isArray(abi) ? (abi as AbiInput[]) : ([abi] as unknown as AbiInput[]), + ); const modifiedParams: Array = []; for (const [index, param] of params.entries()) { diff --git a/packages/web3-eth-abi/src/number_map_type.ts b/packages/web3-eth-abi/src/number_map_type.ts new file mode 100644 index 00000000000..70313fcd6d6 --- /dev/null +++ b/packages/web3-eth-abi/src/number_map_type.ts @@ -0,0 +1,34 @@ +type _SolidityIndexRange = + | 1 + | 2 + | 3 + | 4 + | 5 + | 6 + | 7 + | 8 + | 9 + | 10 + | 11 + | 12 + | 13 + | 14 + | 15 + | 16 + | 17 + | 18 + | 19 + | 20 + | 21 + | 22 + | 25 + | 26 + | 27 + | 28 + | 29 + | 30; + +export type ConvertToNumber< + T extends string, + Range extends number = _SolidityIndexRange, +> = Range extends unknown ? (`${Range}` extends T ? Range : never) : never; diff --git a/packages/web3-eth-abi/src/types.ts b/packages/web3-eth-abi/src/types.ts index 38b97567c95..5d0d176f45f 100644 --- a/packages/web3-eth-abi/src/types.ts +++ b/packages/web3-eth-abi/src/types.ts @@ -1,57 +1,207 @@ -// TODO: Adding reference of source definition/doc for these types -export interface JsonAbiParameter { - readonly name: string; - readonly type: string; - readonly baseType?: string; - readonly indexed?: boolean; - readonly components?: Array; - readonly arrayLength?: number; - readonly arrayChildren?: Array; -} +import { + Address, + Bytes, + Numbers, + ObjectValueToTuple, + ArrayToIndexObject, + FixedSizeArray, +} from 'web3-utils'; +import { ConvertToNumber } from './number_map_type'; -export interface JsonAbiStruct { +export interface AbiStruct { [key: string]: unknown; name?: string; type: string; } -export interface JsonAbiCoderStruct extends JsonAbiStruct { +export interface AbiCoderStruct extends AbiStruct { [key: string]: unknown; - components?: Array; + components?: Array; } -type FragmentTypes = 'constructor' | 'event' | 'function'; +// https://docs.soliditylang.org/en/latest/abi-spec.html#json +export type AbiParameter = { + readonly name: string; + readonly type: string; + readonly baseType?: string; + readonly indexed?: boolean; + readonly components?: ReadonlyArray; + readonly arrayLength?: number; + readonly arrayChildren?: ReadonlyArray; +}; -export interface JsonAbiBaseFragment { - name?: string; - type: FragmentTypes; - inputs?: Array; -} +type FragmentTypes = 'constructor' | 'event' | 'function' | 'fallback'; -export interface JsonAbiConstructorFragment extends JsonAbiBaseFragment { - type: 'constructor'; - stateMutability: 'nonpayable' | 'payable'; -} +export type AbiBaseFragment = { + readonly type: FragmentTypes; +}; + +// https://docs.soliditylang.org/en/latest/abi-spec.html#json +export type AbiConstructorFragment = AbiBaseFragment & { + readonly type: 'constructor'; + readonly stateMutability: 'nonpayable' | 'payable'; + readonly inputs: ReadonlyArray; +}; + +// https://docs.soliditylang.org/en/latest/abi-spec.html#json +export type AbiFunctionFragment = AbiBaseFragment & { + readonly name: string; + readonly type: 'function'; + readonly stateMutability: 'nonpayable' | 'payable' | 'pure' | 'view'; + readonly inputs: ReadonlyArray; + readonly outputs: ReadonlyArray; + + readonly constant?: boolean; // stateMutability == 'pure' or stateMutability == 'view' + readonly payable?: boolean; // stateMutability == 'payable' +}; -export interface JsonAbiFunctionFragment extends JsonAbiBaseFragment { - type: 'function'; - stateMutability: 'nonpayable' | 'payable' | 'pure' | 'view'; - outputs?: Array; +export type AbiFallbackFragment = AbiBaseFragment & { + readonly name?: never; + readonly type: 'fallback'; + readonly stateMutability: 'nonpayable' | 'payable' | 'pure' | 'view'; + readonly inputs?: never; + readonly outputs?: never; // legacy properties - constant?: boolean; // stateMutability == 'pure' or stateMutability == 'view' - payable?: boolean; // stateMutability == 'payable' -} + readonly constant?: boolean; // stateMutability == 'pure' or stateMutability == 'view' + readonly payable?: boolean; // stateMutability == 'payable' +}; -export interface JsonAbiEventFragment extends JsonAbiBaseFragment { - type: 'event'; - anonymous?: boolean; -} +// https://docs.soliditylang.org/en/latest/abi-spec.html#json +export type AbiEventFragment = AbiBaseFragment & { + readonly name: string; + readonly type: 'event'; + readonly inputs: ReadonlyArray; + readonly anonymous?: boolean; +}; // https://docs.soliditylang.org/en/latest/abi-spec.html#json -export type JsonAbiFragment = - | JsonAbiConstructorFragment - | JsonAbiFunctionFragment - | JsonAbiEventFragment; +export type AbiFragment = + | AbiConstructorFragment + | AbiFunctionFragment + | AbiEventFragment + | AbiFallbackFragment; + +export type ContractAbi = ReadonlyArray; + +export type AbiInput = string | AbiParameter | { readonly [key: string]: unknown }; + +export type FilterAbis = Abi extends Filter + ? Abi + : never; + +type _TypedArray = Size extends '' + ? Type[] + : FixedSizeArray>; + +export type PrimitiveAddressType = Type extends `address[${infer Size}]` + ? _TypedArray + : Type extends 'address' + ? Address + : never; + +export type PrimitiveStringType = Type extends `string${string}[${infer Size}]` + ? _TypedArray + : Type extends 'string' | `string${string}` + ? string + : never; + +export type PrimitiveBooleanType = Type extends `bool[${infer Size}]` + ? _TypedArray + : Type extends 'bool' + ? boolean + : never; + +export type PrimitiveIntegerType = Type extends + | `uint${string}[${infer Size}]` + | `int${string}[${infer Size}]` + ? _TypedArray + : Type extends 'uint' | 'int' | `int${string}` | `uint${string}` + ? Numbers + : never; + +export type PrimitiveBytesType = Type extends `bytes${string}[${infer Size}]` + ? _TypedArray + : Type extends 'bytes' | `bytes${string}` + ? Bytes + : never; + +export type PrimitiveTupleType< + Type extends string, + Components extends ReadonlyArray | undefined = [], +> = Components extends ReadonlyArray + ? Type extends 'tuple' + ? { + // eslint-disable-next-line no-use-before-define + [Param in Components[number] as Param['name']]: MatchPrimitiveType< + Param['type'], + Param['components'] + >; + } + : Type extends `tuple[${infer Size}]` + ? _TypedArray< + { + // eslint-disable-next-line no-use-before-define + [Param in Components[number] as Param['name']]: MatchPrimitiveType< + Param['type'], + Param['components'] + >; + }, + Size + > + : never + : never; + +export type MatchPrimitiveType< + Type extends string, + Components extends ReadonlyArray | undefined, +> = + | PrimitiveAddressType + | PrimitiveStringType + | PrimitiveBooleanType + | PrimitiveIntegerType + | PrimitiveBytesType + | PrimitiveTupleType + | never; + +// Only intended to use locally so why not exported +// TODO: Inspect Record not working constraint +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type _ExtractParameterType> = { + [K in keyof T]: MatchPrimitiveType; +}; + +export type ContractMethodOutputParameters> = + ObjectValueToTuple<_ExtractParameterType>>; + +export type ContractMethodInputParameters> = + Params extends readonly [] + ? never + : { + [Param in Params[number] as Param['name']]: MatchPrimitiveType< + Param['type'], + Param['components'] + >; + }; + +export type ContractConstructor = { + [Abi in FilterAbis as 'constructor']: { + readonly Abi: Abi; + readonly Inputs: ContractMethodInputParameters; + }; +}['constructor']; + +export type ContractMethods = { + [Abi in FilterAbis as Abi['name']]: { + readonly Abi: Abi; + readonly Inputs: ContractMethodInputParameters; + readonly Outputs: ContractMethodOutputParameters; + }; +}; -export type AbiInput = string | JsonAbiParameter | { readonly [key: string]: unknown }; +export type ContractEvents = { + [Abi in FilterAbis as Abi['name']]: { + readonly Abi: Abi; + readonly Inputs: ContractMethodInputParameters; + }; +}; diff --git a/packages/web3-eth-abi/src/utils.ts b/packages/web3-eth-abi/src/utils.ts index 4eac49aee2e..20cce30a41a 100644 --- a/packages/web3-eth-abi/src/utils.ts +++ b/packages/web3-eth-abi/src/utils.ts @@ -4,37 +4,37 @@ import { leftPad, rightPad, toHex } from 'web3-utils'; import ethersAbiCoder from './ethers_abi_coder'; import { AbiInput, - JsonAbiCoderStruct, - JsonAbiFragment, - JsonAbiParameter, - JsonAbiStruct, - JsonAbiEventFragment, - JsonAbiFunctionFragment, - JsonAbiConstructorFragment, + AbiCoderStruct, + AbiFragment, + AbiParameter, + AbiStruct, + AbiEventFragment, + AbiFunctionFragment, + AbiConstructorFragment, } from './types'; -export const isAbiFragment = (item: unknown): item is JsonAbiFragment => +export const isAbiFragment = (item: unknown): item is AbiFragment => item !== undefined && item !== null && typeof item === 'object' && (item as { type: string }).type !== undefined && ['function', 'event', 'constructor'].includes((item as { type: string }).type); -export const isAbiEventFragment = (item: unknown): item is JsonAbiEventFragment => +export const isAbiEventFragment = (item: unknown): item is AbiEventFragment => item !== undefined && item !== null && typeof item === 'object' && (item as { type: string }).type !== undefined && (item as { type: string }).type === 'event'; -export const isAbiFunctionFragment = (item: unknown): item is JsonAbiFunctionFragment => +export const isAbiFunctionFragment = (item: unknown): item is AbiFunctionFragment => item !== undefined && item !== null && typeof item === 'object' && (item as { type: string }).type !== undefined && (item as { type: string }).type === 'function'; -export const isAbiConstructorFragment = (item: unknown): item is JsonAbiConstructorFragment => +export const isAbiConstructorFragment = (item: unknown): item is AbiConstructorFragment => item !== undefined && item !== null && typeof item === 'object' && @@ -45,8 +45,8 @@ export const isAbiConstructorFragment = (item: unknown): item is JsonAbiConstruc * Check if type is simplified struct format */ export const isSimplifiedStructFormat = ( - type: string | Partial, -): type is Omit => + type: string | Partial, +): type is Omit => typeof type === 'object' && typeof (type as { components: unknown }).components === 'undefined' && typeof (type as { name: unknown }).name === 'undefined'; @@ -54,7 +54,7 @@ export const isSimplifiedStructFormat = ( /** * Maps the correct tuple type and name when the simplified format in encode/decodeParameter is used */ -export const mapStructNameAndType = (structName: string): JsonAbiStruct => +export const mapStructNameAndType = (structName: string): AbiStruct => structName.includes('[]') ? { type: 'tuple[]', name: structName.slice(0, -2) } : { type: 'tuple', name: structName }; @@ -62,8 +62,8 @@ export const mapStructNameAndType = (structName: string): JsonAbiStruct => /** * Maps the simplified format in to the expected format of the ABICoder */ -export const mapStructToCoderFormat = (struct: JsonAbiStruct): Array => { - const components: Array = []; +export const mapStructToCoderFormat = (struct: AbiStruct): Array => { + const components: Array = []; for (const key of Object.keys(struct)) { const item = struct[key]; @@ -71,7 +71,7 @@ export const mapStructToCoderFormat = (struct: JsonAbiStruct): Array> => { - const mappedTypes: Array> = []; +): Array> => { + const mappedTypes: Array> = []; for (const type of types) { let modifiedType = type; @@ -112,8 +112,8 @@ export const mapTypes = ( mappedTypes.push({ ...mapStructNameAndType(structName), components: mapStructToCoderFormat( - modifiedType[structName] as unknown as JsonAbiStruct, - ) as unknown as JsonAbiParameter[], + modifiedType[structName] as unknown as AbiStruct, + ) as unknown as AbiParameter[], }); } else { mappedTypes.push(modifiedType); @@ -217,7 +217,10 @@ export const modifyParams = ( * used to flatten json abi inputs/outputs into an array of type-representing-strings */ -export const flattenTypes = (includeTuple: boolean, puts: JsonAbiParameter[]): string[] => { +export const flattenTypes = ( + includeTuple: boolean, + puts: ReadonlyArray, +): string[] => { const types: string[] = []; puts.forEach(param => { @@ -250,10 +253,15 @@ export const flattenTypes = (includeTuple: boolean, puts: JsonAbiParameter[]): s * Should be used to create full function/event name from json abi * returns a string */ -export const jsonInterfaceMethodToString = (json: JsonAbiFragment): string => { - if (json.name?.includes('(')) { - return json.name; +export const jsonInterfaceMethodToString = (json: AbiFragment): string => { + if (isAbiEventFragment(json) || isAbiFunctionFragment(json)) { + if (json.name?.includes('(')) { + return json.name; + } + + return `${json.name ?? ''}(${flattenTypes(false, json.inputs ?? []).join(',')})`; } - return `${json.name ?? ''}(${flattenTypes(false, json.inputs ?? []).join(',')})`; + // Constructor fragment + return `(${flattenTypes(false, json.inputs ?? []).join(',')})`; }; diff --git a/packages/web3-eth-abi/test/unit/types.test.ts b/packages/web3-eth-abi/test/unit/types.test.ts new file mode 100644 index 00000000000..d6d0efa34ec --- /dev/null +++ b/packages/web3-eth-abi/test/unit/types.test.ts @@ -0,0 +1,143 @@ +import { Address, Bytes, Numbers } from 'web3-utils'; +import { expectTypeOf, typecheck } from '@humeris/espresso-shot'; +import { MatchPrimitiveType } from '../../src/types'; + +describe('types', () => { + describe('primitive types', () => { + describe('bool', () => { + typecheck('should extend the boolean type', () => + expectTypeOf>().toExtend(), + ); + + typecheck('should extend the boolean type array', () => + expectTypeOf>().toExtend(), + ); + + typecheck('should extend the boolean type fixed array', () => + expectTypeOf>().toExtend< + [boolean, boolean, boolean] + >(), + ); + }); + + describe('string', () => { + typecheck('should extend the string type', () => + expectTypeOf>().toExtend(), + ); + + typecheck('should extend the string type array', () => + expectTypeOf>().toExtend(), + ); + + typecheck('should extend the string type fixed array', () => + expectTypeOf>().toExtend< + [string, string, string] + >(), + ); + }); + + describe('address', () => { + typecheck('should extend correct type', () => + expectTypeOf>().toExtend
(), + ); + + typecheck('should extend the correct type array', () => + expectTypeOf>().toExtend(), + ); + + typecheck('should extend the correct type fixed array', () => + expectTypeOf>().toExtend< + [Address, Address, Address] + >(), + ); + }); + + describe('bytes', () => { + typecheck('should extend correct type', () => + expectTypeOf>().toExtend(), + ); + + typecheck('should extend correct type with size', () => + expectTypeOf>().toExtend(), + ); + + typecheck('should extend the correct type array', () => + expectTypeOf>().toExtend(), + ); + + typecheck('should extend the correct type fixed array', () => + expectTypeOf>().toExtend< + [Bytes, Bytes, Bytes] + >(), + ); + }); + + describe('uint', () => { + typecheck('should extend correct type', () => + expectTypeOf>().toExtend(), + ); + + typecheck('should extend correct type with size', () => + expectTypeOf>().toExtend(), + ); + + typecheck('should extend correct type with size array', () => + expectTypeOf>().toExtend(), + ); + + typecheck('should extend the correct type array', () => + expectTypeOf>().toExtend(), + ); + + typecheck('should extend the correct type fixed array', () => + expectTypeOf>().toExtend< + [Numbers, Numbers, Numbers] + >(), + ); + }); + + describe('int', () => { + typecheck('should extend correct type', () => + expectTypeOf>().toExtend(), + ); + + typecheck('should extend correct type with size', () => + expectTypeOf>().toExtend(), + ); + + typecheck('should extend correct type with size array', () => + expectTypeOf>().toExtend(), + ); + + typecheck('should extend the correct type array', () => + expectTypeOf>().toExtend(), + ); + + typecheck('should extend the correct type fixed array', () => + expectTypeOf>().toExtend< + [Numbers, Numbers, Numbers] + >(), + ); + }); + + describe('tuple', () => { + typecheck('should extend correct type', () => + expectTypeOf< + MatchPrimitiveType<'tuple', [{ type: 'uint'; name: 'a' }]> + >().toExtend<{ a: Numbers }>(), + ); + + typecheck('should extend correct type with size array', () => + expectTypeOf< + MatchPrimitiveType<'tuple[3]', [{ type: 'uint'; name: 'a' }]> + >().toExtend<[{ a: Numbers }, { a: Numbers }, { a: Numbers }]>(), + ); + + typecheck('should extend the correct type array', () => + expectTypeOf< + MatchPrimitiveType<'tuple[]', [{ type: 'uint'; name: 'a' }]> + >().toExtend<{ a: Numbers }[]>(), + ); + }); + }); +}); diff --git a/packages/web3-eth-contract/README.md b/packages/web3-eth-contract/README.md index 9672b1077b0..2b1df11f5f0 100644 --- a/packages/web3-eth-contract/README.md +++ b/packages/web3-eth-contract/README.md @@ -30,6 +30,29 @@ yarn add web3-eth-contract - :gear: [NodeJS](https://nodejs.org/) (LTS/Fermium) - :toolbox: [Yarn](https://yarnpkg.com/)/[Lerna](https://lerna.js.org/) +## Usage + +You can initialize the typesafe Contract API instance with the following. + +```ts +import { Contract } from 'web3-eth-contract'; + +const abi = [...] as const; + +const contract = new Contract(abi); +``` + +- We prefer that you use `web3.eth.Contract` API in normal usage. +- The use of `as const` is necessary to have fully type-safe interface for the contract. +- As the ABIs are not extensive in size, we suggest declaring them `as const` in your TS project. +- This approach is more flexible and seamless compared to other approaches of off-line compiling ABIs to TS interfaces (such as [TypeChain](https://github.com/dethcrypto/TypeChain). + +## Compatibility + +We have tested the Typescript interface support for the ABIs compiled with solidity version `v0.4.x` and above. If you face any issue regarding the contract typing, please create an issue to report to us. + +The Typescript support for fixed length array types are supported up 30 elements. See more details [here](https://github.com/ChainSafe/web3.js/blob/nh%2F4562-contract-typing/packages/web3-eth-abi/src/number_map_type.ts#L1). This limitation is only to provide more performant developer experience in IDEs. In future we may come up with a workaround to avoid this limitation. If you have any idea feel free to share. + ## Package.json Scripts | Script | Description | diff --git a/packages/web3-eth-contract/package.json b/packages/web3-eth-contract/package.json index 7ece0f19f0a..93124dcc2cb 100644 --- a/packages/web3-eth-contract/package.json +++ b/packages/web3-eth-contract/package.json @@ -26,11 +26,12 @@ }, "dependencies": { "web3-common": "1.0.0-alpha.0", - "web3-eth-abi": "4.0.0-alpha.0", "web3-core": "4.0.0-alpha.0", + "web3-eth-abi": "4.0.0-alpha.0", "web3-utils": "4.0.0-alpha.0" }, "devDependencies": { + "@humeris/espresso-shot": "^4.0.0", "@types/jest": "^27.0.3", "@typescript-eslint/eslint-plugin": "^5.4.0", "@typescript-eslint/parser": "^5.4.0", diff --git a/packages/web3-eth-contract/src/contract.ts b/packages/web3-eth-contract/src/contract.ts index 0ca38714fa3..aa607e3b2b6 100644 --- a/packages/web3-eth-contract/src/contract.ts +++ b/packages/web3-eth-contract/src/contract.ts @@ -1,25 +1,37 @@ -import { EthExecutionAPI, inputAddressFormatter } from 'web3-common'; +// eslint-disable-next-line max-classes-per-file +import { EthExecutionAPI, inputAddressFormatter, Web3EventEmitter } from 'web3-common'; import { Web3Context } from 'web3-core'; import { + AbiEventFragment, + AbiFunctionFragment, + ContractAbi, + ContractEvents, + ContractMethods, encodeEventSignature, encodeFunctionSignature, isAbiEventFragment, isAbiFunctionFragment, - JsonAbiEventFragment, - JsonAbiFragment, - JsonAbiFunctionFragment, jsonInterfaceMethodToString, } from 'web3-eth-abi'; import { Address, toChecksumAddress } from 'web3-utils'; -import { ContractInitOptions, ContractOptions } from './types'; +import { + ContractEventEmitterInterface, + ContractEventsInterface, + ContractInitOptions, + ContractMethodsInterface, + ContractOptions, +} from './types'; type ContractBoundFunction = string; type ContractBoundEvent = string; -export class Contract extends Web3Context { +export class Contract + extends Web3Context + implements Web3EventEmitter>> +{ public readonly options: ContractOptions; - private _jsonInterface!: JsonAbiFragment[]; + private _jsonInterface!: Abi; private _address?: Address | null; private _functions: Record< string, @@ -31,13 +43,17 @@ export class Contract extends Web3Context { > = {}; private _events: Record = {}; - public constructor( - jsonInterface: JsonAbiFragment[], - address?: Address, - options?: ContractInitOptions, - ) { + public readonly methods: ContractMethodsInterface>; + public readonly events: ContractEventsInterface>; + + public constructor(jsonInterface: Abi, address?: Address, options?: ContractInitOptions) { super(options?.provider ?? ''); + // TODO: Implement these methods + this.methods = {} as ContractMethodsInterface>; + // TODO: Implement these events + this.events = {} as ContractEventsInterface>; + this._parseAndSetAddress(address); this._parseAndSetJsonInterface(jsonInterface); @@ -56,7 +72,7 @@ export class Contract extends Web3Context { }); Object.defineProperty(this.options, 'jsonInterface', { - set: (value: JsonAbiFragment[]) => this._parseAndSetJsonInterface(value), + set: (value: ContractAbi) => this._parseAndSetJsonInterface(value), get: () => this._jsonInterface, }); } @@ -65,10 +81,10 @@ export class Contract extends Web3Context { this._address = value ? toChecksumAddress(inputAddressFormatter(value)) : null; } - private _parseAndSetJsonInterface(abis: JsonAbiFragment[]) { + private _parseAndSetJsonInterface(abis: ContractAbi) { this._functions = {}; this._events = {}; - const result: JsonAbiFragment[] = []; + let result: ContractAbi = []; for (const a of abis) { const abi = { @@ -110,22 +126,23 @@ export class Contract extends Web3Context { this._events[signature] = event; } - result.push(abi); + result = [...result, abi]; } - this._jsonInterface = abis; + this._jsonInterface = [...result] as unknown as Abi; } // eslint-disable-next-line class-methods-use-this private _createContractFunction( name: string, - _abi: JsonAbiFunctionFragment, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _abi: AbiFunctionFragment, ): ContractBoundFunction { return name; } - // eslint-disable-next-line class-methods-use-this - private _createContractEvent(name: string, _abi: JsonAbiEventFragment): ContractBoundEvent { + // eslint-disable-next-line class-methods-use-this, @typescript-eslint/no-unused-vars + private _createContractEvent(name: string, _abi: AbiEventFragment): ContractBoundEvent { return name; } } diff --git a/packages/web3-eth-contract/src/encoding.ts b/packages/web3-eth-contract/src/encoding.ts index e84f8f65bbb..2ddce254213 100644 --- a/packages/web3-eth-contract/src/encoding.ts +++ b/packages/web3-eth-contract/src/encoding.ts @@ -5,16 +5,17 @@ import { encodeParameter, encodeParameters, isAbiConstructorFragment, - JsonAbiConstructorFragment, - JsonAbiEventFragment, - JsonAbiFunctionFragment, + AbiConstructorFragment, + AbiEventFragment, + AbiFunctionFragment, + isAbiFunctionFragment, } from 'web3-eth-abi'; import { HexString, Uint } from 'web3-utils'; import { ContractOptions } from './types'; export const encodeEventABI = ( { address }: ContractOptions, - event: JsonAbiEventFragment & { signature: string }, + event: AbiEventFragment & { signature: string }, options?: { fromBlock?: Uint; toBlock?: Uint; @@ -84,7 +85,7 @@ export const encodeEventABI = ( }; export const decodeEventABI = ( - event: JsonAbiEventFragment & { signature: string }, + event: AbiEventFragment & { signature: string }, data: LogsInput, ) => { let modifiedEvent = { ...event }; @@ -121,7 +122,7 @@ export const decodeEventABI = ( return { ...result, - returnValue: decodeLog(event.inputs ?? [], data.data, argTopics), + returnValue: decodeLog([...event.inputs], data.data, argTopics), event: event.name, signature: event.anonymous || !data.topics[0] ? null : data.topics[0], raw: { @@ -132,11 +133,11 @@ export const decodeEventABI = ( }; export const encodeMethodABI = ( - abi: (JsonAbiFunctionFragment | JsonAbiConstructorFragment) & { signature?: string }, + abi: (AbiFunctionFragment | AbiConstructorFragment) & { signature?: string }, args: unknown[], deployData?: HexString, ) => { - if (abi.signature && abi.name !== abi.signature) { + if (isAbiFunctionFragment(abi) && abi.signature && abi.name !== abi.signature) { throw new Error('The ABI can not match with given signature'); } @@ -176,7 +177,7 @@ export const encodeMethodABI = ( }; export const decodeMethodReturn = ( - abi: (JsonAbiFunctionFragment | JsonAbiConstructorFragment) & { signature?: string }, + abi: (AbiFunctionFragment | AbiConstructorFragment) & { signature?: string }, returnValues?: HexString, ) => { if (!returnValues) { @@ -184,7 +185,7 @@ export const decodeMethodReturn = ( } const value = returnValues.length >= 2 ? returnValues.slice(2) : returnValues; - const result = decodeParameters(abi.inputs ?? [], value); + const result = decodeParameters([...abi.inputs], value); if (result.__length__ === 1) { return result[0]; diff --git a/packages/web3-eth-contract/src/types.ts b/packages/web3-eth-contract/src/types.ts index 57a2def6b84..ad09fc04fa8 100644 --- a/packages/web3-eth-contract/src/types.ts +++ b/packages/web3-eth-contract/src/types.ts @@ -1,14 +1,39 @@ -import { Uint, Address, Bytes } from 'web3-utils'; +import { EventEmitter } from 'events'; +import { EthExecutionAPI, PromiEvent, ReceiptInfo } from 'web3-common'; import { SupportedProviders } from 'web3-core'; -import { EthExecutionAPI } from 'web3-common'; -import { JsonAbiFragment } from 'web3-eth-abi'; +import { ContractAbi, ContractEvents, ContractMethods } from 'web3-eth-abi'; +import { Address, Bytes, Numbers, Uint, HexString, BlockNumberOrTag } from 'web3-utils'; + +export type Callback = (error: Error, result: T) => void; + +export interface EventLog { + event: string; + address: string; + returnValues: unknown; + logIndex: number; + transactionIndex: number; + transactionHash: string; + blockHash: string; + blockNumber: number; + raw?: { data: string; topics: unknown[] }; +} + +export interface ContractEventLog extends EventLog { + returnValues: T; +} + +export interface ContactEventOptions { + filter?: object; + fromBlock?: BlockNumberOrTag; + topics?: string[]; +} export interface ContractOptions { readonly gas: Uint | null; readonly gasPrice: Uint | null; readonly from?: Address; readonly data?: Bytes; - jsonInterface: JsonAbiFragment[]; + jsonInterface: ContractAbi; address?: Address | null; } @@ -20,3 +45,95 @@ export interface ContractInitOptions { readonly gasLimit: Uint; readonly provider: SupportedProviders | string; } + +export type TransactionReceipt = ReceiptInfo; + +export interface NonPayableTx { + nonce?: Numbers; + chainId?: Numbers; + from?: Address; + to?: Address; + data?: HexString; + gas?: Numbers; + maxPriorityFeePerGas?: Numbers; + maxFeePerGas?: Numbers; + gasPrice?: Numbers; +} + +export interface PayableTx extends NonPayableTx { + value?: Numbers; +} + +export interface NonPayableTransactionObject { + arguments: Array; + call(tx?: NonPayableTx, block?: BlockNumberOrTag): Promise; + send(tx?: NonPayableTx): PromiEvent< + TransactionReceipt, + { + sending: object; + sent: object; + transactionHash: string; + receipt: TransactionReceipt; + confirmation: { + confirmations: number; + receipt: TransactionReceipt; + latestBlockHash: HexString; + }; + error: Error; + } + >; + estimateGas(tx?: NonPayableTx): Promise; + encodeABI(): string; +} + +export interface PayableTransactionObject { + arguments: Array; + call(tx?: PayableTx, block?: BlockNumberOrTag): Promise; + send(tx?: PayableTx): PromiEvent< + TransactionReceipt, + { + sending: object; + sent: object; + transactionHash: string; + receipt: TransactionReceipt; + confirmation: { + confirmations: number; + receipt: TransactionReceipt; + latestBlockHash: HexString; + }; + error: Error; + } + >; + estimateGas(tx?: PayableTx): Promise; + encodeABI(): string; +} + +export type ContractMethodsInterface< + Abi extends ContractAbi, + Methods extends ContractMethods, +> = { + [Name in keyof Methods]: ( + ...args: Array + ) => // TODO: Debug why the `Abi` object is not accessible. + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + Methods[Name]['Abi']['stateMutability'] extends 'payable' | 'pure' + ? PayableTransactionObject + : NonPayableTransactionObject; +}; + +export type ContractEventsInterface> = { + [Name in keyof Events]: + | ((cb: Callback>) => EventEmitter) + | (( + options: ContactEventOptions, + cb: Callback>, + ) => EventEmitter); +}; + +export type ContractEventEmitterInterface< + Abi extends ContractAbi, + Events extends ContractEvents, +> = { + [Name in keyof Events]: Events[Name]['Inputs']; +}; diff --git a/packages/web3-eth-contract/test/fixtures/erc20.ts b/packages/web3-eth-contract/test/fixtures/erc20.ts new file mode 100644 index 00000000000..bd8364b1a39 --- /dev/null +++ b/packages/web3-eth-contract/test/fixtures/erc20.ts @@ -0,0 +1,316 @@ +import { EventEmitter } from 'events'; +import { Address, Numbers } from 'web3-utils'; +import { + ContractEventLog, + NonPayableTransactionObject, + PayableTransactionObject, + Callback, + ContactEventOptions, +} from '../../src/types'; + +export interface Erc20Interface { + methods: { + name: (args: never) => PayableTransactionObject; + + approve: ( + _spender: string, + _value: Numbers, + ) => NonPayableTransactionObject< + { + _spender: string; + _value: Numbers; + }, + [boolean] + >; + totalSupply: (args: never) => NonPayableTransactionObject; + + transferFrom: ( + _from: Address, + _to: Address, + _value: Numbers, + ) => NonPayableTransactionObject< + { + _from: Address; + _to: Address; + _value: Numbers; + }, + [boolean] + >; + + decimals: (args: never) => NonPayableTransactionObject; + + balanceOf: (_owner: Address) => NonPayableTransactionObject<{ _owner: Address }, [Numbers]>; + + symbol: (args: never) => NonPayableTransactionObject; + + transfer: ( + _to: Address, + _value: Numbers, + ) => NonPayableTransactionObject< + { + _to: Address; + _value: Numbers; + }, + [boolean] + >; + + allowance: ( + _owner: Address, + _spender: Address, + ) => NonPayableTransactionObject< + { + _owner: Address; + _spender: Address; + }, + [Numbers] + >; + }; + + events: { + Approval: + | (( + cb: Callback< + ContractEventLog<{ owner: Address; spender: Address; value: Numbers }> + >, + ) => EventEmitter) + | (( + options: ContactEventOptions, + cb: Callback< + ContractEventLog<{ owner: Address; spender: Address; value: Numbers }> + >, + ) => EventEmitter); + + Transfer: + | (( + cb: Callback>, + ) => EventEmitter) + | (( + options: ContactEventOptions, + cb: Callback>, + ) => EventEmitter); + }; +} + +// https://ethereumdev.io/abi-for-erc20-contract-on-ethereum/ +export const erc20Abi = [ + { + constant: true, + inputs: [], + name: 'name', + outputs: [ + { + name: '', + type: 'string', + }, + ], + payable: false, + stateMutability: 'view', + type: 'function', + }, + { + constant: false, + inputs: [ + { + name: '_spender', + type: 'address', + }, + { + name: '_value', + type: 'uint256', + }, + ], + name: 'approve', + outputs: [ + { + name: '', + type: 'bool', + }, + ], + payable: false, + stateMutability: 'nonpayable', + type: 'function', + }, + { + constant: true, + inputs: [], + name: 'totalSupply', + outputs: [ + { + name: '', + type: 'uint256', + }, + ], + payable: false, + stateMutability: 'view', + type: 'function', + }, + { + constant: false, + inputs: [ + { + name: '_from', + type: 'address', + }, + { + name: '_to', + type: 'address', + }, + { + name: '_value', + type: 'uint256', + }, + ], + name: 'transferFrom', + outputs: [ + { + name: '', + type: 'bool', + }, + ], + payable: false, + stateMutability: 'nonpayable', + type: 'function', + }, + { + constant: true, + inputs: [], + name: 'decimals', + outputs: [ + { + name: '', + type: 'uint8', + }, + ], + payable: false, + stateMutability: 'view', + type: 'function', + }, + { + constant: true, + inputs: [ + { + name: '_owner', + type: 'address', + }, + ], + name: 'balanceOf', + outputs: [ + { + name: 'balance', + type: 'uint256', + }, + ], + payable: false, + stateMutability: 'view', + type: 'function', + }, + { + constant: true, + inputs: [], + name: 'symbol', + outputs: [ + { + name: '', + type: 'string', + }, + ], + payable: false, + stateMutability: 'view', + type: 'function', + }, + { + constant: false, + inputs: [ + { + name: '_to', + type: 'address', + }, + { + name: '_value', + type: 'uint256', + }, + ], + name: 'transfer', + outputs: [ + { + name: '', + type: 'bool', + }, + ], + payable: false, + stateMutability: 'nonpayable', + type: 'function', + }, + { + constant: true, + inputs: [ + { + name: '_owner', + type: 'address', + }, + { + name: '_spender', + type: 'address', + }, + ], + name: 'allowance', + outputs: [ + { + name: '', + type: 'uint256', + }, + ], + payable: false, + stateMutability: 'view', + type: 'function', + }, + { + payable: true, + stateMutability: 'payable', + type: 'fallback', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + name: 'owner', + type: 'address', + }, + { + indexed: true, + name: 'spender', + type: 'address', + }, + { + indexed: false, + name: 'value', + type: 'uint256', + }, + ], + name: 'Approval', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + name: 'from', + type: 'address', + }, + { + indexed: true, + name: 'to', + type: 'address', + }, + { + indexed: false, + name: 'value', + type: 'uint256', + }, + ], + name: 'Transfer', + type: 'event', + }, +] as const; diff --git a/packages/web3-eth-contract/test/fixtures/erc721.ts b/packages/web3-eth-contract/test/fixtures/erc721.ts new file mode 100644 index 00000000000..829886b1d0a --- /dev/null +++ b/packages/web3-eth-contract/test/fixtures/erc721.ts @@ -0,0 +1,353 @@ +import { EventEmitter } from 'events'; +import { Address, Numbers } from 'web3-utils'; +import { + ContractEventLog, + NonPayableTransactionObject, + PayableTransactionObject, + Callback, + ContactEventOptions, +} from '../../src/types'; + +export interface Erc721Interface { + methods: { + getApproved: ( + _tokenId: Numbers, + ) => NonPayableTransactionObject<{ _tokenId: Numbers }, [Address]>; + approve: ( + _approved: Address, + _tokenId: Numbers, + ) => NonPayableTransactionObject<{ _approved: Address; _tokenId: Numbers }, []>; + transferFrom: ( + _from: Address, + _to: Address, + _tokenId: Numbers, + ) => PayableTransactionObject<{ _from: Address; _to: Address; _tokenId: Numbers }, []>; + safeTransferFrom: ( + _from: Address, + _to: Address, + _tokenId: Numbers, + ) => NonPayableTransactionObject<{ _from: Address; _to: Address; _tokenId: Numbers }, []>; + ownerOf: ( + _tokenId: Numbers, + ) => NonPayableTransactionObject<{ _tokenId: Numbers }, [Address]>; + balanceOf: (_owner: Address) => NonPayableTransactionObject<{ _owner: Address }, [Numbers]>; + setApprovalForAll: ( + _operator: Address, + _approved: boolean, + ) => NonPayableTransactionObject<{ _operator: Address; _approved: boolean }, []>; + + isApprovedForAll: ( + _owner: Address, + _operator: Address, + ) => NonPayableTransactionObject<{ _owner: Address; _operator: Address }, [boolean]>; + }; + + events: { + Transfer: + | (( + cb: Callback< + ContractEventLog<{ _from: Address; _to: Address; _tokenId: Numbers }> + >, + ) => EventEmitter) + | (( + options: ContactEventOptions, + cb: Callback< + ContractEventLog<{ _from: Address; _to: Address; _tokenId: Numbers }> + >, + ) => EventEmitter); + + Approval: + | (( + cb: Callback< + ContractEventLog<{ _owner: Address; _approved: Address; _tokenId: Numbers }> + >, + ) => EventEmitter) + | (( + options: ContactEventOptions, + cb: Callback< + ContractEventLog<{ _owner: Address; _approved: Address; _tokenId: Numbers }> + >, + ) => EventEmitter); + + ApprovalForAll: + | (( + cb: Callback< + ContractEventLog<{ + _owner: Address; + _operator: Address; + _approved: boolean; + }> + >, + ) => EventEmitter) + | (( + options: ContactEventOptions, + cb: Callback< + ContractEventLog<{ + _owner: Address; + _operator: Address; + _approved: boolean; + }> + >, + ) => EventEmitter); + }; +} + +// https://eips.ethereum.org/EIPS/eip-721 +// Copied interface from above link to Remix and compile +export const erc721Abi = [ + { + constant: true, + inputs: [ + { + name: '_tokenId', + type: 'uint256', + }, + ], + name: 'getApproved', + outputs: [ + { + name: '', + type: 'address', + }, + ], + payable: false, + stateMutability: 'view', + type: 'function', + }, + { + constant: false, + inputs: [ + { + name: '_approved', + type: 'address', + }, + { + name: '_tokenId', + type: 'uint256', + }, + ], + name: 'approve', + outputs: [], + payable: true, + stateMutability: 'payable', + type: 'function', + }, + { + constant: false, + inputs: [ + { + name: '_from', + type: 'address', + }, + { + name: '_to', + type: 'address', + }, + { + name: '_tokenId', + type: 'uint256', + }, + ], + name: 'transferFrom', + outputs: [], + payable: true, + stateMutability: 'payable', + type: 'function', + }, + { + constant: false, + inputs: [ + { + name: '_from', + type: 'address', + }, + { + name: '_to', + type: 'address', + }, + { + name: '_tokenId', + type: 'uint256', + }, + ], + name: 'safeTransferFrom', + outputs: [], + payable: true, + stateMutability: 'payable', + type: 'function', + }, + { + constant: true, + inputs: [ + { + name: '_tokenId', + type: 'uint256', + }, + ], + name: 'ownerOf', + outputs: [ + { + name: '', + type: 'address', + }, + ], + payable: false, + stateMutability: 'view', + type: 'function', + }, + { + constant: true, + inputs: [ + { + name: '_owner', + type: 'address', + }, + ], + name: 'balanceOf', + outputs: [ + { + name: '', + type: 'uint256', + }, + ], + payable: false, + stateMutability: 'view', + type: 'function', + }, + { + constant: false, + inputs: [ + { + name: '_operator', + type: 'address', + }, + { + name: '_approved', + type: 'bool', + }, + ], + name: 'setApprovalForAll', + outputs: [], + payable: false, + stateMutability: 'nonpayable', + type: 'function', + }, + + // TODO: Need to check how to handle overloads in generics + // This is overloaded method + { + constant: false, + inputs: [ + { + name: '_from', + type: 'address', + }, + { + name: '_to', + type: 'address', + }, + { + name: '_tokenId', + type: 'uint256', + }, + { + name: 'data', + type: 'bytes', + }, + ], + name: 'safeTransferFrom', + outputs: [], + payable: true, + stateMutability: 'payable', + type: 'function', + }, + { + constant: true, + inputs: [ + { + name: '_owner', + type: 'address', + }, + { + name: '_operator', + type: 'address', + }, + ], + name: 'isApprovedForAll', + outputs: [ + { + name: '', + type: 'bool', + }, + ], + payable: false, + stateMutability: 'view', + type: 'function', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + name: '_from', + type: 'address', + }, + { + indexed: true, + name: '_to', + type: 'address', + }, + { + indexed: true, + name: '_tokenId', + type: 'uint256', + }, + ], + name: 'Transfer', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + name: '_owner', + type: 'address', + }, + { + indexed: true, + name: '_approved', + type: 'address', + }, + { + indexed: true, + name: '_tokenId', + type: 'uint256', + }, + ], + name: 'Approval', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + name: '_owner', + type: 'address', + }, + { + indexed: true, + name: '_operator', + type: 'address', + }, + { + indexed: false, + name: '_approved', + type: 'bool', + }, + ], + name: 'ApprovalForAll', + type: 'event', + }, +] as const; diff --git a/packages/web3-eth-contract/test/unit/contract_typing.test.ts b/packages/web3-eth-contract/test/unit/contract_typing.test.ts new file mode 100644 index 00000000000..7966f66f58a --- /dev/null +++ b/packages/web3-eth-contract/test/unit/contract_typing.test.ts @@ -0,0 +1,48 @@ +/* eslint-disable jest/expect-expect */ + +import { expectTypeOf, typecheck } from '@humeris/espresso-shot'; +import { Contract } from '../../src/contract'; +import { erc20Abi, Erc20Interface } from '../fixtures/erc20'; +import { erc721Abi, Erc721Interface } from '../fixtures/erc721'; + +describe('contract typing', () => { + describe('erc20', () => { + const contract = new Contract(erc20Abi); + + typecheck('should contain all methods', () => + expectTypeOf().toBe(), + ); + + typecheck('should have interface compliance methods', () => + expectTypeOf(contract.methods).toExtend(), + ); + + typecheck('should have all events', () => + expectTypeOf(contract.events).toExtend(), + ); + + typecheck('should have interface compliance events', () => + expectTypeOf(contract.events).toExtend(), + ); + }); + + describe('erc721', () => { + const contract = new Contract(erc721Abi); + + typecheck('should contain all methods', () => + expectTypeOf().toBe(), + ); + + typecheck('should have interface compliance methods', () => + expectTypeOf(contract.methods).toExtend(), + ); + + typecheck('should have all events', () => + expectTypeOf(contract.events).toExtend(), + ); + + typecheck('should have interface compliance events', () => + expectTypeOf(contract.events).toExtend(), + ); + }); +}); diff --git a/packages/web3-utils/src/types.ts b/packages/web3-utils/src/types.ts index 3618d9bf0f2..f1f8a915582 100644 --- a/packages/web3-utils/src/types.ts +++ b/packages/web3-utils/src/types.ts @@ -41,6 +41,47 @@ export type TypedObjectAbbreviated = { v: EncodingTypes; }; +export type IndexKeysForArray = Exclude; + +export type IntersectionOfUnion = (U extends unknown ? (k: U) => void : never) extends ( + k: infer I, +) => void + ? I + : never; + +export type LastOf = IntersectionOfUnion< + T extends unknown ? () => T : never +> extends () => infer R + ? R + : never; + +export type Push = [...T, V]; + +export type UnionToTuple, N = [T] extends [never] ? true : false> = true extends N + ? [] + : Push>, L>; + +export type ObjectValueToTuple< + T, + KS extends unknown[] = UnionToTuple, + R extends unknown[] = [], +> = KS extends [infer K, ...infer KT] ? ObjectValueToTuple : R; + +export type ArrayToIndexObject> = { + [K in IndexKeysForArray]: T[K]; +}; + +type _Grow> = ((x: T, ...xs: A) => void) extends (...a: infer X) => void + ? X + : never; + +export type GrowToSize, N extends number> = { + 0: A; + 1: GrowToSize, N>; +}[A['length'] extends N ? 0 : 1]; + +export type FixedSizeArray = GrowToSize; + export type Components = { name: string; type: string; diff --git a/yarn.lock b/yarn.lock index c17e9244d26..453c92a7d4b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -524,6 +524,18 @@ resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45" integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA== +"@humeris/boule@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@humeris/boule/-/boule-4.0.0.tgz#50eec1208f3d747585ecd6040b34641f0ba81931" + integrity sha512-kaD0eg61QZb8Qwymx/DW1bOoFwzuWiG9lW3ik3dD8//HDEvr15OSykXdJECLgQPfMrS4RuLGHU+lmJkrHk8XoA== + +"@humeris/espresso-shot@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@humeris/espresso-shot/-/espresso-shot-4.0.0.tgz#e720d2bd3d555a3118e788931a01a991308f8cfe" + integrity sha512-cVmyBfgrutiBmQcMIxfCVnTb2UBgl00dGIuwoOCjeeASOz9/rPtiXxbic9n+8RXXvTNt+mLfxssPMLf7yb7U5A== + dependencies: + "@humeris/boule" "^4.0.0" + "@hutson/parse-repository-url@^3.0.0": version "3.0.2" resolved "https://registry.yarnpkg.com/@hutson/parse-repository-url/-/parse-repository-url-3.0.2.tgz#98c23c950a3d9b6c8f0daed06da6c3af06981340"