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: replace ethers abi coder with ours #6385

Merged
merged 16 commits into from
Sep 28, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
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 package.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
"buffer": "^6.0.3",
"bufferutil": "^4.0.6",
"clean-webpack-plugin": "^4.0.0",
"concurrently": "^8.2.0",
"cypress-jest-adapter": "^0.1.1",
"declaration-bundler-webpack-plugin": "^1.0.3",
"eslint": "^8.20.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/web3-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"scripts": {
"clean": "rimraf dist && rimraf lib",
"prebuild": "yarn clean",
"build": "yarn build:cjs & yarn build:esm & yarn build:types",
"build": "concurrently --kill-others-on-fail \"yarn:build:*(!check)\"",
"build:cjs": "tsc --build tsconfig.cjs.json && echo '{\"type\": \"commonjs\"}' > ./lib/commonjs/package.json",
"build:esm": "tsc --build tsconfig.esm.json && echo '{\"type\": \"module\"}' > ./lib/esm/package.json",
"build:types": "tsc --build tsconfig.types.json",
Expand Down
2 changes: 1 addition & 1 deletion packages/web3-errors/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"scripts": {
"clean": "rimraf dist && rimraf lib",
"prebuild": "yarn clean",
"build": "yarn build:cjs & yarn build:esm & yarn build:types",
"build": "concurrently --kill-others-on-fail \"yarn:build:*(!check)\"",
"build:cjs": "tsc --build tsconfig.cjs.json && echo '{\"type\": \"commonjs\"}' > ./lib/commonjs/package.json",
"build:esm": "tsc --build tsconfig.esm.json && echo '{\"type\": \"module\"}' > ./lib/esm/package.json",
"build:types": "tsc --build tsconfig.types.json",
Expand Down
6 changes: 6 additions & 0 deletions packages/web3-errors/src/errors/generic_errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,12 @@ export class OperationAbortError extends BaseWeb3Error {

export class AbiError extends BaseWeb3Error {
public code = ERR_ABI_ENCODING;
public readonly props: Record<string, unknown> & { name?: string };

public constructor(message: string, props?: Record<string, unknown> & { name?: string }) {
super(message);
mpetrunic marked this conversation as resolved.
Show resolved Hide resolved
this.props = props ?? {};
}
}

export class ExistingPluginNamespaceError extends BaseWeb3Error {
Expand Down
6 changes: 3 additions & 3 deletions packages/web3-eth-abi/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"scripts": {
"clean": "rimraf dist && rimraf lib",
"prebuild": "yarn clean",
"build": "yarn build:cjs & yarn build:esm & yarn build:types",
"build": "concurrently --kill-others-on-fail \"yarn:build:*(!check)\"",
"build:cjs": "tsc --build tsconfig.cjs.json && echo '{\"type\": \"commonjs\"}' > ./lib/commonjs/package.json",
"build:esm": "tsc --build tsconfig.esm.json && echo '{\"type\": \"module\"}' > ./lib/esm/package.json",
"build:types": "tsc --build tsconfig.types.json",
Expand All @@ -42,8 +42,8 @@
"test:integration": "jest --config=./test/integration/jest.config.js --passWithNoTests"
},
"dependencies": {
"@ethersproject/abi": "^5.7.0",
"@ethersproject/bignumber": "^5.7.0",
"abitype": "0.7.1",
"web3-validator": "^2.0.2",
"web3-errors": "^1.1.2",
"web3-types": "^1.2.0",
"web3-utils": "^4.0.6"
Expand Down
79 changes: 9 additions & 70 deletions packages/web3-eth-abi/src/api/parameters_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,10 @@ GNU Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public License
along with web3.js. If not, see <http://www.gnu.org/licenses/>.
*/

import { AbiError } from 'web3-errors';
import { ParamType, Result } from '@ethersproject/abi';
import { HexString, AbiInput, DecodedParams } from 'web3-types';
import ethersAbiCoder from '../ethers_abi_coder.js';
import { formatParam, isAbiFragment, mapTypes, modifyParams } from '../utils.js';
import { AbiInput, HexString } from 'web3-types';
import { decodeParameters as decodeParametersInternal } from '../coders/decode.js';
import { encodeParameters as encodeParametersInternal } from '../coders/encode.js';

/**
* Encodes a parameter based on its type to its ABI representation.
Expand All @@ -37,40 +35,8 @@ import { formatParam, isAbiFragment, mapTypes, modifyParams } from '../utils.js'
* > 0x000000000000000000000000000000000000000000000000000000008bd02b7b0000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000748656c6c6f212500000000000000000000000000000000000000000000000000
* ```
*/
export const encodeParameters = (abi: ReadonlyArray<AbiInput>, params: unknown[]): string => {
try {
const modifiedTypes = mapTypes(
Array.isArray(abi) ? (abi as AbiInput[]) : ([abi] as unknown as AbiInput[]),
);
const modifiedParams: Array<unknown> = [];
for (const [index, param] of params.entries()) {
const item = modifiedTypes[index];
let type: string;

if (isAbiFragment(item) && item.type) {
// We may get a named type of shape {name, type}
type = item.type;
} else {
type = item as unknown as string;
}

const newParam = formatParam(type, param);

if (typeof type === 'string' && type.includes('tuple')) {
const coder = ethersAbiCoder._getCoder(ParamType.from(type));
modifyParams(coder, [newParam]);
}

modifiedParams.push(newParam);
}
return ethersAbiCoder.encode(
modifiedTypes.map(p => ParamType.from(p)),
modifiedParams,
);
} catch (err) {
throw new AbiError(`Parameter encoding error`, err as Error);
}
};
export const encodeParameters = (abi: ReadonlyArray<AbiInput>, params: unknown[]): string =>
encodeParametersInternal(abi, params);

/**
* Encodes a parameter based on its type to its ABI representation.
Expand Down Expand Up @@ -130,30 +96,6 @@ export const encodeParameters = (abi: ReadonlyArray<AbiInput>, params: unknown[]
*/
export const encodeParameter = (abi: AbiInput, param: unknown): string =>
encodeParameters([abi], [param]);

// If encoded param is an array and there are mixed on integer and string keys
const isParamRequiredToConvert = (data: Result): boolean =>
Array.isArray(data) &&
Object.keys(data).filter(k => Number.isInteger(+k)).length !== Object.keys(data).length;

// Ethers-Encoder return the decoded result as an array with additional string indexes for named params
// We want these to be converted to an object with named keys
const formatArrayResToObject = (data: Result): DecodedParams => {
const returnValue: DecodedParams = {
__length__: 0,
};

for (const key of Object.keys(data)) {
returnValue[key] =
Array.isArray(data[key]) && isParamRequiredToConvert(data[key] as Result)
? formatArrayResToObject(data[key] as Result)
: data[key];

returnValue.__length__ += Number.isInteger(+key) ? 1 : 0;
}
return returnValue;
};

/**
* Should be used to decode list of params
*/
Expand All @@ -172,14 +114,11 @@ export const decodeParametersWith = (
'or querying a node which is not fully synced.',
);
}
const res = ethersAbiCoder.decode(
mapTypes(abis).map(p => ParamType.from(p)),
`0x${bytes.replace(/0x/i, '')}`,
loose,
);
return formatArrayResToObject(res);
return decodeParametersInternal(abis, `0x${bytes.replace(/0x/i, '')}`, loose);
} catch (err) {
throw new AbiError(`Parameter decoding error: ${(err as Error).message}`);
throw new AbiError(`Parameter decoding error: ${(err as Error).message}`, {
internalErr: err,
});
mpetrunic marked this conversation as resolved.
Show resolved Hide resolved
}
};

Expand Down
75 changes: 75 additions & 0 deletions packages/web3-eth-abi/src/coders/base/address.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/*
This file is part of web3.js.

web3.js is free software: you can redistribute it and/or modify
it under the terms of the GNU Lesser General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

web3.js is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Lesser General Public License for more details.

You should have received a copy of the GNU Lesser General Public License
along with web3.js. If not, see <http://www.gnu.org/licenses/>.
*/
import { AbiError } from 'web3-errors';
import { AbiParameter } from 'web3-types';
import { toChecksumAddress } from 'web3-utils';
import { isAddress, utils } from 'web3-validator';
import { DecoderResult, EncoderResult } from '../types.js';
import { alloc, WORD_SIZE } from '../utils.js';

const ADDRESS_BYTES_COUNT = 20;
const ADDRESS_OFFSET = WORD_SIZE - ADDRESS_BYTES_COUNT;

export function encodeAddress(param: AbiParameter, input: unknown): EncoderResult {
if (typeof input !== 'string') {
throw new AbiError('address type expects string as input type', {
value: input,
name: param.name,
type: param.type,
});
}
let address = input.toLowerCase();
if (!address.startsWith('0x')) {
address = `0x${address}`;
}
if (!isAddress(address)) {
throw new AbiError('provided input is not valid address', {
value: input,
name: param.name,
type: param.type,
});
}
// for better performance, we could convert hex to destination bytes directly (encoded var)
const addressBytes = utils.hexToUint8Array(address);
// expand address to WORD_SIZE
const encoded = alloc(WORD_SIZE);
encoded.set(addressBytes, ADDRESS_OFFSET);
return {
dynamic: false,
encoded,
};
}

export function decodeAddress(_param: AbiParameter, bytes: Uint8Array): DecoderResult<string> {
const addressBytes = bytes.subarray(ADDRESS_OFFSET, WORD_SIZE);
if (addressBytes.length !== ADDRESS_BYTES_COUNT) {
throw new AbiError('Invalid decoding input, not enough bytes to decode address', { bytes });
}
const result = utils.uint8ArrayToHexString(addressBytes);

// should we check is decoded value is valid address?
// if(!isAddress(result)) {
// throw new AbiError("encoded data is not valid address", {
// address: result,
// });
// }
return {
result: toChecksumAddress(result),
encoded: bytes.subarray(WORD_SIZE),
consumed: WORD_SIZE,
};
}
120 changes: 120 additions & 0 deletions packages/web3-eth-abi/src/coders/base/array.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/*
This file is part of web3.js.

web3.js is free software: you can redistribute it and/or modify
it under the terms of the GNU Lesser General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

web3.js is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Lesser General Public License for more details.

You should have received a copy of the GNU Lesser General Public License
along with web3.js. If not, see <http://www.gnu.org/licenses/>.
*/
import { AbiError } from 'web3-errors';
import { AbiParameter } from 'web3-types';
import { uint8ArrayConcat } from 'web3-utils';
// eslint-disable-next-line import/no-cycle
import { decodeParamFromAbiParameter, encodeNumber, encodeParamFromAbiParameter } from '.';
import { DecoderResult, EncoderResult } from '../types.js';
import { extractArrayType, isDynamic, WORD_SIZE } from '../utils.js';
import { decodeNumber } from './number.js';
import { encodeDynamicParams } from './utils.js';

export function encodeArray(param: AbiParameter, values: unknown): EncoderResult {
if (!Array.isArray(values)) {
throw new AbiError('Expected value to be array', { abi: param, values });
}
const { size, param: arrayItemParam } = extractArrayType(param);
const encodedParams = values.map(v => encodeParamFromAbiParameter(arrayItemParam, v));
const dynamic = size === -1;
const dynamicItems = encodedParams.length > 0 && encodedParams[0].dynamic;
if (!dynamic && values.length !== size) {
throw new AbiError("Given arguments count doesn't match array length", {
Copy link
Contributor

Choose a reason for hiding this comment

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

Errors here and everywhere else in the MR seems to inturduce breaking changes, right?
Are we planning on upgreading the major version after this MR? Or else we need to keep the errors as they where, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

What do you mean? its the same error instance as before. Adding new error messages is not a breaking chnage

Copy link
Contributor

Choose a reason for hiding this comment

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

I think this is not just a new error message. The code used to throw an error for this case, and for others, but with a different message and a different structure, right?
The old message was according to the old deleted code:
throw new AbiError(`Parameter encoding error`, err as Error); And the innerError was the error object we get from the encoder.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Code still throws AbiError error and our api doesn't guarantee inner error type will stay the same (It will be of type Error | Error[] if present). It's user's fault if he expected something that typescript types did not guarantee.

Copy link
Contributor

Choose a reason for hiding this comment

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

Thanks @mpetrunic,
Yes, it is not guaranteed by typescript types here. But still the user can even use JavaScript which has no types. I am thinking here about the actual strings and values that the user used to get and might already has ifs checks on. And if we think it is the user's fault to depend on types we did not specify, changing them is still considered a breaking change, as far as I know.

So, as far as I know, if we do not maintain to provide the exact message string and the exact internal properties for every error, as we used to, then we are introducing a breaking change. And yes we can add more properties to the error object. But we are not supposed to remove or update the string or a property value, without pumping the major version.

And yes, our API has been used to guarantee the same message and properties's values for the innerError message because the early used library is following semver.

And I think keeping the error messages and properties, as they were, is possible even though it might be a bit tricky.

Please correct me if changing an error message, or a value of an internal property inside an error object, is not considered a breaking change.

Thanks,

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Afaik, error messages are implementation details intended for the developer's eyes. No one should rely on their exact content or even worse make an if/else statement based on their content (fixing a typo in an error message would then be a breaking change). If the developer intended for you to handle those errors, errors would either be different instances or contain error codes along with the list of error codes the function might throw.

arrayLength: size,
argumentsLength: values.length,
});
}
if (dynamic || dynamicItems) {
const encodingResult = encodeDynamicParams(encodedParams);
if (dynamic) {
const encodedLength = encodeNumber(
{ type: 'uint256', name: '' },
encodedParams.length,
).encoded;
return {
dynamic: true,
encoded:
encodedParams.length > 0
? uint8ArrayConcat(encodedLength, encodingResult)
: encodedLength,
};
}
return {
dynamic: true,
encoded: encodingResult,
};
}

return {
dynamic: false,
encoded: uint8ArrayConcat(...encodedParams.map(p => p.encoded)),
};
}

export function decodeArray(param: AbiParameter, bytes: Uint8Array): DecoderResult<unknown[]> {
// eslint-disable-next-line prefer-const
let { size, param: arrayItemParam } = extractArrayType(param);
const dynamic = size === -1;

let consumed = 0;
const result: unknown[] = [];
let remaining = bytes;
// dynamic array, we need to decode length
if (dynamic) {
const lengthResult = decodeNumber({ type: 'uint32', name: '' }, bytes);
size = Number(lengthResult.result);
consumed = lengthResult.consumed;
remaining = lengthResult.encoded;
}
const hasDynamicChild = isDynamic(arrayItemParam);
if (hasDynamicChild) {
// known length but dynamic child, each child is actually head element with encoded offset
for (let i = 0; i < size; i += 1) {
const offsetResult = decodeNumber(
{ type: 'uint32', name: '' },
remaining.subarray(i * WORD_SIZE),
);
consumed += offsetResult.consumed;
const decodedChildResult = decodeParamFromAbiParameter(
arrayItemParam,
remaining.subarray(Number(offsetResult.result)),
);
consumed += decodedChildResult.consumed;
result.push(decodedChildResult.result);
}
return {
result,
encoded: remaining.subarray(consumed),
consumed,
};
}

for (let i = 0; i < size; i += 1) {
// decode static params
const decodedChildResult = decodeParamFromAbiParameter(
arrayItemParam,
bytes.subarray(consumed),
);
consumed += decodedChildResult.consumed;
result.push(decodedChildResult.result);
}
return {
result,
encoded: bytes.subarray(consumed),
consumed,
};
}
Loading