Skip to content

Expose revert data field in TransactionException for decoding CustomError #2180

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

Open
dgimenez27 opened this issue Apr 21, 2025 · 2 comments
Open
Labels
needs-review issue/PR needs review from maintainer

Comments

@dgimenez27
Copy link

# Issue_title

Expose revert data field in TransactionException class for decoding CustomError

Issue_description

Currently, when executing a transaction using .send() and it fails on-chain (e.g. due to a revert), Web3J throws a TransactionException. This exception provides access to the error message (execution reverted) and optionally the transaction hash or receipt.

However, it does not expose the data field from the JSON-RPC error response, which is critical for decoding custom errors (CustomError) returned by the contract.

Issue_context

Imagine calling a contract function with .send() and catching a TransactionException.

💡 Example

try {
    contractInstance.withdraw(...params).send();
} catch (TransactionException ex) {
    // Can't access revert reason data here
}

The actual JSON-RPC response from the node may include a field like "data": "0x..." which contains the ABI-encoded custom error selector and parameters.

{
  "jsonrpc": "2.0",
  "id": 4,
  "error": {
    "code": 3,
    "message": "execution reverted",
    "data": "0xe2517d3f000000000000000000000000..." // selector + ABI-encoded params
  }
}

The data field contains the selector and ABI-encoded parameters of the custom error, which can be decoded using the new CustomError feature added in PR #2173.

🧩 Problem

  • The data field is currently not mapped in org.web3j.protocol.core.Response.Error
  • TransactionException does not expose it either
  • As a result, it's not possible to decode CustomError revert reasons from .send()
  • Developers must work around this by using manual JSON-RPC calls via external HTTP clients

✅ Proposed enhancement

  1. Extend Response.Error to include a data field (i.e., the full revert payload)
  2. Add support in TransactionException to receive and expose that field
  3. Ensure that if the JSON-RPC error includes data, it is accessible from the exception
@JsonProperty("data")
private String data;

private final String revertData;
public String getRevertData();

✅ Optional

Consider adding utilities to:

  • Extract the 4-byte selector from the data
  • Match it against CustomError instances generated by Web3J wrappers

🎯 Benefits

  • Enables decoding of custom error revert reasons directly from Web3J .send() workflows
  • Eliminates the need to manually replicate eth_call logic using HTTP clients
  • Complements the existing support for CustomError added in recent PRs
  • Improves developer experience, debugging, and UX

🏷 Suggested labels

  • enhancement
  • custom-error
  • revert-reason
  • ux-improvement

📎 Related

@dgimenez27 dgimenez27 added the needs-review issue/PR needs review from maintainer label Apr 21, 2025
@psychoplasma
Copy link
Contributor

💡 Example

try {
    contractInstance.withdraw(...params).send();
} catch (TransactionException ex) {
    // Can't access revert reason data here
}

I'm not sure what network and node(live network, local network, or emulator like org.web3j.evm.EmbeddedWeb3jService) you're using, but you can actually access revert reason(error selector and abi-encoded parameters as you mentioned) in above example from TransactionException if there is one. Have you tried ex.getTransactionReceipt().getRevertReason() ?

The reason why you get execution reverted as error message is that the node you're using for RPC-JSON API does not provide revert reasons which should be enabled through node's configuration. For example, if you're using free-tier Infura, you won't get the actual revert reason, instead you you'll get execution reverted as revert reason like you did. Most of the 3rd-party providers do not provide revert reason for free-tiers.

Regarding decoding a revert reason for a contract in interest,
I guess it could be handled in Contract.java with an abstracted method call for populating CustomErrors from the contract in interest like getContractErrors(). However, the issue would be that the decoding logic(whether decode into a string or another structure, and if it's a string, how to format it etc.) itself would be very dependent, I mean there is no such standard or so for error decoding. The decoding logic does indeed belong to the client.

I think, the best would be like trying to decode revert reason into a CustomError and throwing a separate exception if the revert reason can be decoded into CustomError. For example, in Contract.executeTransaction() (this can be applied to Contract.executeCall() as well);

// In Contract.java line 431
        ...
        if (!(receipt instanceof EmptyTransactionReceipt)
                && receipt != null
                && !receipt.isStatusOK()) {

        String revertReason = extractRevertReason(receipt, data, web3j, true, weiValue);

        Optional<CustomError> customError = this.decodeRevertReasonIntoCustomError(revertReason);
        if (customError.isPresent()) {
            throw new ContractCustomErrorException(customError.get());
        }

            throw new TransactionException(
                    String.format(
                            "Transaction %s has failed with status: %s. "
                                    + "Gas used: %s. "
                                    + "Revert reason: '%s'.",
                            receipt.getTransactionHash(),
                            receipt.getStatus(),
                            receipt.getGasUsedRaw() != null
                                    ? receipt.getGasUsed().toString()
                                    : "unknown",
                            revertReason),
                    receipt);
        }
        return receipt;
    }

    private Optional<CustomError> decodeRevertReasonIntoCustomError(String revertReason) {
        return getCustomErrors().stream()
                .filter(error -> revertReason.startsWith(CustomErrorEncoder.encode(error).substring(0, 10)))
                .findFirst();
    }

    // This is to be implemented in the generated contract
    // Returns all the CustomErrors defined in the generated contract
    protected abstract List<CustomError> getCustomErrors();

    // Exception specific to custom error case, so this can be caught when calling contract functions
    class ContractCustomErrorException extends Exception {
        private final CustomError customError;

        public ContractCustomErrorException(CustomError customError) {
            super( String.format("Transaction is reverted with custom error, %s", customError.getName()));
            this.customError = customError;
        }

        public CustomError getCustomError() {
            return customError;
        }
    }
    ...
.
.
.

// And then in client code
try {
    contractInstance.withdraw(...params).send();
} catch (ContractCustomErrorException ex) {
    CustomError error = ex.getCustomError();
    // handle the error
} catch (TransactionException ex) {
    // handle this
}

@dgimenez27
Copy link
Author

TL;DR: I'm proposing that Web3J expose the error.data field (from JSON-RPC responses) inside TransactionException or a similar structure. This field contains essential ABI-encoded information like CustomError selectors and parameters, and without it, client applications can't decode revert reasons — even though the node provides them.


Hi @psychoplasma,

Thanks a lot for reviewing this and for your thorough explanation.

I believe part of my original intent might have been misunderstood, so allow me to add a little more context.

I'm using Web3J v4.14.0 and interacting with an Alchemy node (free tier).
In this setup, the node does return the full error.data field in the JSON-RPC response when a transaction is reverted — as shown below:

{
	"jsonrpc": "2.0",
	"id": 4,
	"error": {
		"code": 3,
		"message": "execution reverted",
		"data": "0xe2517d3f000000000000000000000000..." // selector + ABI-encoded params
	}
}

However, calling ex.getTransactionReceipt().getRevertReason() only returns execution reverted.
This happens because the TransactionException does not capture the data field from the original JSON-RPC response. This is the key problem.

I'm not suggesting that Web3J should decode the CustomError internally. What I'm proposing is that Web3J should at least expose the raw data field so that client applications can decide how to handle or decode it.

The library only needs to expose the data field. Once available, client applications can use tools like FunctionReturnDecoder along with the generated CustomError classes to decode and interpret it as needed—whether by extracting the selector, the parameters, or both in their raw or structured form.

What I suggest in simple terms is:

  • Extend Response.Error to include a data field (i.e., the full revert payload)
  • Let TransactionException (or a subclass) expose that field via something like getRevertData()
  • [Optionally] offer a utility that extracts the 4-byte selector or decodes it using the CustomError class

This doesn't alter current behavior — it's additive, safe, and very helpful in real-world scenarios.

Now, regarding your design suggestion for ContractCustomErrorException and getCustomErrors() — I really appreciate the thought and agree it's a great idea.

Having a structured mechanism like that to match and throw decoded custom errors would definitely improve the developer experience. In fact, I think it aligns perfectly with the feature introduced in [PR #2173] and builds upon it very elegantly.

But I’d like to emphasize that this proposal is not mutually exclusive with what I'm suggesting.

The key differences are:

  • Your proposal enhances how contract-level code could decode a revert into a specific CustomError
  • My proposal enhances transport-level visibility, so the client can decide if, how, and when to decode anything at all

Without access to the data field at all, neither the contract class logic nor the client can decode anything — which is why I believe exposing the data field is a necessary foundation and the first step for all future improvements in this area.

That said, I’m happy to see you're open to this direction and would be glad to help explore how both ideas could be implemented cohesively.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
needs-review issue/PR needs review from maintainer
Projects
None yet
Development

No branches or pull requests

2 participants