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: introduce CustomError handling for EthCall #2146

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
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
58 changes: 58 additions & 0 deletions abi/src/main/java/org/web3j/abi/Utils.java
Original file line number Diff line number Diff line change
Expand Up @@ -331,4 +331,62 @@ private static String getClassName(Class type) {

return type.getName();
}

/**
* Gets the Solidity type name for a given TypeReference.
* This method handles both simple types and complex types (arrays, structs).
*
* @param typeReference the TypeReference to get the Solidity type name for
* @return the Solidity type name (e.g. "uint256", "address", "string[]")
*/
public static String getSolidityTypeName(TypeReference<?> typeReference) {
try {
java.lang.reflect.Type reflectedType = typeReference.getType();
Class<?> type;

if (reflectedType instanceof ParameterizedType) {
type = (Class<?>) ((ParameterizedType) reflectedType).getRawType();
if (DynamicArray.class.isAssignableFrom(type)) {
Class<?> componentType = getParameterizedTypeFromArray(typeReference);
return getSoliditySimpleTypeName(componentType) + "[]";
} else if (StaticArray.class.isAssignableFrom(type)) {
Class<?> componentType = getParameterizedTypeFromArray(typeReference);
int length = ((TypeReference.StaticArrayTypeReference) typeReference).getSize();
return getSoliditySimpleTypeName(componentType) + "[" + length + "]";
}
}

type = typeReference.getClassType();
return getSoliditySimpleTypeName(type);
} catch (ClassNotFoundException e) {
throw new UnsupportedOperationException("Invalid class reference provided", e);
}
}

private static String getSoliditySimpleTypeName(Class<?> type) {
if (Uint.class.isAssignableFrom(type)) {
return "uint256";
} else if (Int.class.isAssignableFrom(type)) {
return "int256";
} else if (Ufixed.class.isAssignableFrom(type)) {
return "ufixed256";
} else if (Fixed.class.isAssignableFrom(type)) {
return "fixed256";
} else if (Utf8String.class.isAssignableFrom(type)) {
return "string";
} else if (DynamicBytes.class.isAssignableFrom(type)) {
return "bytes";
} else if (org.web3j.abi.datatypes.Address.class.isAssignableFrom(type)) {
return "address";
} else if (org.web3j.abi.datatypes.Bool.class.isAssignableFrom(type)) {
return "bool";
} else if (org.web3j.abi.datatypes.Bytes.class.isAssignableFrom(type)) {
String typeName = type.getSimpleName();
return typeName.toLowerCase();
} else if (StructType.class.isAssignableFrom(type)) {
return getStructType(type);
} else {
return type.getSimpleName().toLowerCase();
}
}
}
90 changes: 90 additions & 0 deletions abi/src/main/java/org/web3j/abi/datatypes/CustomError.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/*
* Copyright 2024 Web3 Labs Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
* specific language governing permissions and limitations under the License.
*/
package org.web3j.abi.datatypes;

import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

import org.web3j.abi.TypeReference;
import org.web3j.abi.Utils;

/**
* Represents a Solidity custom error definition.
* This class defines the structure of a custom error, not its values.
* The parameters list contains TypeReferences that define what types of values
* the error can contain, not the actual values themselves.
*/
public class CustomError {
private final String name;
private final List<TypeReference<?>> parameters;

public CustomError(String name, List<TypeReference<?>> parameters) {
this.name = name;
this.parameters = parameters;
}

public CustomError(String name) {
this(name, new ArrayList<>());
}

public String getName() {
return name;
}

public List<TypeReference<?>> getParameters() {
return parameters;
}

/**
* Returns the error signature in the format "ErrorName(type1,type2,...)"
*/
public String getSignature() {
StringBuilder signature = new StringBuilder();
signature.append(name).append("(");
for (int i = 0; i < parameters.size(); i++) {
signature.append(Utils.getSolidityTypeName(parameters.get(i)));
if (i < parameters.size() - 1) {
signature.append(",");
}
}
signature.append(")");
return signature.toString();
}

/**
* Returns the first 4 bytes of the Keccak-256 hash of the error signature.
* This is used to identify the error in transaction receipts.
*/
public String getSelector() {
return org.web3j.crypto.Hash.sha3String(getSignature()).substring(0, 10);
}

@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
CustomError that = (CustomError) o;
return this.getSignature().equals(that.getSignature());
}

@Override
public int hashCode() {
return getSignature().hashCode();
}

@Override
public String toString() {
return getSignature();
}
}
142 changes: 142 additions & 0 deletions abi/src/test/java/org/web3j/abi/datatypes/CustomErrorTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/*
* Copyright 2024 Web3 Labs Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
* specific language governing permissions and limitations under the License.
*/
package org.web3j.abi.datatypes;

import java.util.Arrays;
import java.util.Collections;
import java.util.List;

import org.junit.jupiter.api.Test;

import org.web3j.abi.TypeReference;
import org.web3j.abi.datatypes.AbiTypes;
import org.web3j.abi.datatypes.generated.Uint256;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;

public class CustomErrorTest {

@Test
public void testEmptyCustomError() {
CustomError error = new CustomError("SimpleError");
assertEquals(
"SimpleError()",
error.getSignature(),
"Empty error should have signature 'SimpleError()'"
);
assertEquals(
"0xc2bb947c",
error.getSelector(),
"Selector should match keccak256('SimpleError()')"
);
}

@Test
public void testCustomErrorWithParameters() {
List<TypeReference<?>> parameters = Arrays.asList(
TypeReference.create(Address.class),
TypeReference.create(Uint256.class),
TypeReference.create(Utf8String.class)
);

CustomError error = new CustomError("ComplexError", parameters);

String expectedSignature = "ComplexError(address,uint256,string)";
assertEquals(
expectedSignature,
error.getSignature(),
"Error signature should match '" + expectedSignature + "'"
);
assertEquals(
parameters,
error.getParameters(),
"Parameters list should match the input parameters"
);
assertEquals(
"0xcca85a17",
error.getSelector(),
"Selector should match keccak256('" + expectedSignature + "')"
);
}

@Test
public void testCustomErrorWithArrayParameter() {
List<TypeReference<?>> parameters = Collections.singletonList(
new TypeReference<DynamicArray<Uint256>>() {}
);

CustomError error = new CustomError("ArrayError", parameters);

String expectedSignature = "ArrayError(uint256[])";
assertEquals(
expectedSignature,
error.getSignature(),
"Error signature should match '" + expectedSignature + "'"
);
assertEquals(
parameters,
error.getParameters(),
"Parameters list should match the input parameters"
);
assertEquals(
"0x6300af57",
error.getSelector(),
"Selector should match keccak256('" + expectedSignature + "')"
);
}

@Test
public void testEquality() {
List<TypeReference<?>> parameters1 = Arrays.asList(
TypeReference.create(Address.class),
TypeReference.create(Uint256.class)
);

List<TypeReference<?>> parameters2 = Arrays.asList(
TypeReference.create(Address.class),
TypeReference.create(Uint256.class)
);

CustomError error1 = new CustomError("TestError", parameters1);
CustomError error2 = new CustomError("TestError", parameters2);
CustomError error3 = new CustomError("DifferentError", parameters1);

assertEquals(
error1,
error2,
"Errors with same name and parameters should be equal"
);
assertNotEquals(
error1,
error3,
"Errors with different names should not be equal"
);
}

@Test
public void testToString() {
List<TypeReference<?>> parameters = Arrays.asList(
TypeReference.create(Address.class),
TypeReference.create(Uint256.class)
);

CustomError error = new CustomError("TestError", parameters);
String expectedString = "TestError(address,uint256)";
assertEquals(
expectedString,
error.toString(),
"toString() should return the error signature '" + expectedString + "'"
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,16 @@

import java.util.Collections;
import java.util.List;
import java.util.ArrayList;
import java.util.stream.Collectors;

import org.web3j.abi.FunctionReturnDecoder;
import org.web3j.abi.FunctionEncoder;
import org.web3j.abi.TypeReference;
import org.web3j.abi.datatypes.AbiTypes;
import org.web3j.abi.datatypes.Type;
import org.web3j.abi.datatypes.Utf8String;
import org.web3j.abi.datatypes.CustomError;
import org.web3j.protocol.core.Response;
import org.web3j.utils.EnsUtils;

Expand Down Expand Up @@ -66,4 +70,53 @@ public String getRevertReason() {
}
return null;
}

/**
* Gets the revert reason as a string for a custom error.
* If the revert data matches the supplied custom error definition, returns a formatted string
* with the error name and decoded parameters. Otherwise, returns null.
*
* @param customError The custom error definition.
* @return the custom error revert reason as a string, or null if not a custom error.
*/
@SuppressWarnings("unchecked")
public String getRevertReason(CustomError customError) {
if (!hasError() && !isReverted()) {
return null;
}

String data = getValue();
if (data == null || data.length() < 10) { // At least 4 bytes for selector
return null;
}

// Calculate error selector and compare with the supplied custom error
String customErrorSelector = customError.getSelector();
String actualSelector = data.substring(0, 10);

if (!customErrorSelector.equals(actualSelector)) {
return null;
}

// Get the encoded parameters after the selector
String encodedParameters = data.substring(10);
if (encodedParameters.isEmpty()) {
return customError.getName() + "()";
}

// Convert TypeReference<?> to TypeReference<Type>
List<TypeReference<Type>> typeReferences = customError.getParameters().stream()
.map(param -> (TypeReference<Type>) param)
.collect(Collectors.toList());

// Decode parameters using the custom error's parameter types
List<Type> decodedParameters = FunctionReturnDecoder.decode(encodedParameters, typeReferences);

// Convert parameters into a string representation.
String parametersString = decodedParameters.stream()
.map(t -> t.getValue() == null ? "null" : t.getValue().toString())
.collect(Collectors.joining(", "));

return customError.getName() + "(" + parametersString + ")";
}
}