ERC-6093: Support solidity 0.8.4+ custom errors
https://soliditylang.org/blog/2021/04/21/custom-errors/
Contract errors are not parsed from ABI by Contract class and the revert data is not part of IOError to be parsed manually.
Example script:
#!/usr/bin/env ruby
# frozen_string_literal: true
require 'eth'
require 'active_support'
require 'active_support/core_ext/hash/indifferent_access'
require 'digest'
# Connect to Ethereum RPC (Infura example)
rpc_url = "https://base-rpc.publicnode.com"
client = Eth::Client.create(rpc_url)
# Load ERC-721 contract details
contract_address = "0x6f813e6430a223e3ac285144fa9857cb38a642a6"
token_id = 1
# ERC-721 ABI method signature for transferFrom(address, address, uint256)
abi = [
{
"constant": true,
"inputs": [
{
"name": "tokenId",
"type": "uint256"
}
],
"name": "ownerOf",
"outputs": [
{
"name": "",
"type": "address"
}
],
"payable": false,
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
}
],
"name": "ERC721NonexistentToken",
"type": "error"
},
].map(&:with_indifferent_access)
contract = Eth::Contract.from_abi(name: "ERC721", address: contract_address, abi: abi)
result = client.call(contract, "ownerOf", token_id)
pp result
RPC Response:
{"jsonrpc"=>"2.0", "id"=>1, "error"=>{
"code"=>3,
"message"=>"execution reverted",
"data"=>"0x7e2732890000000000000000000000000000000000000000000000000000000000000001"}}
Thrown error:
/Users/bogdan/.rvm/gems/ruby-3.3.4/gems/eth-0.5.13/lib/eth/client.rb:485:in `send_command': execution reverted (IOError)
from /Users/bogdan/.rvm/gems/ruby-3.3.4/gems/eth-0.5.13/lib/eth/client.rb:394:in `block (2 levels) in <class:Client>'
from /Users/bogdan/.rvm/gems/ruby-3.3.4/gems/eth-0.5.13/lib/eth/client.rb:454:in `call_raw'
from /Users/bogdan/.rvm/gems/ruby-3.3.4/gems/eth-0.5.13/lib/eth/client.rb:263:in `call'
Thanks for bringing this up. Just to clarify - would you prefer that the gem returns the full response instead of raising an error so that you can handle the error on your end?
Throwing an error with all parsed data is better. I think current IOError is too basic. JS libs work this way.
Ok got it. Can you take a look? I implemented custom solidity errors (if data is present), like the following:
/home/user/.src/q9f/eth.rb/lib/eth/client.rb:493:in `send_command': {"jsonrpc"=>"2.0", "id"=>1, "error"=>{"code"=>3, "message"=>"execution reverted", "data"=>"0x7e2732890000000000000000000000000000000000000000000000000000000000000001"}} (Eth::Client::CustomSolidityError)
from /home/user/.src/q9f/eth.rb/lib/eth/client.rb:400:in `block (2 levels) in <class:Client>'
from /home/user/.src/q9f/eth.rb/lib/eth/client.rb:460:in `call_raw'
from /home/user/.src/q9f/eth.rb/lib/eth/client.rb:269:in `call'
from err.rb:56:in `<main>'
You can do something like
rescue Client::CustomSolidityError => e
pp JSON.parse(e.message.gsub("=>", ":"))
end
This is good step forward, but the error could be parsed better. The custom solidity errors are parsed the same way as events. They have parameters and those parameters have solidity types. Here is how it is parsed using JS viem library:
Details: VM Exception while processing transaction: reverted with custom error
'ERC721NonexistentToken(79106826802597624183325779265297774265409121481891170477833790770152883893012)'
Ideally, we should do the same.
Ok, got it. I just read the documentation.
The compiler includes all errors that a contract can emit in the contract's ABI-JSON. Note that this will not include errors forwarded through external calls. Similarly, developers can provide NatSpec documentation for errors which would then be part of the user and developer documentation and can explain the error in much more detail at no cost.
I'll spend some time thinking how this would be best implemented. We already have the logic now to raise a custom solidity error.
Probably next step is to implement a rescue somewhere where we are calling the contact (and thus having access to the ABI) which can pretty-print the error using the contract's ABI.
Is there a specification on custom errors in ABI?
contract.abi.last["name"] does not seem to be a safe accessor for that.
See, when I saw this library I was surprised on how contract functions are called:
client.call(contract, "ownerOf", token_id)
This is weird. As I would expect a function to be called on a contract because only the contract is aware of a function, but not the client.
All js libraries I used do it this way. After using web3.js, ethers and viem over years, I think the best way to implement function calling is like this:
func = contract.function("safeTransferFrom", from, to, tokenId)
# eth_call to make sure the function doesn't revert
# experienced developers know that eth_estimateGas returns nonsense when function reverts
func.call()
# eth_estimateGas to make sure it is under the adequate limit
func.estimate_gas
tx = func.sign(key)
# eth_sendTransaction
client.eth_send_raw_transaction(tx.hex)
# store tx somewhere to ensure retries are possible
Tx.create!(hex: tx.hex)
If you want some KISS for v1, just have:
contract.call("safeTransferFrom", from, to, tokenId)
contract.estimate_gas(....)
contract.sign(...)
Is there a specification on custom errors in ABI?
I didn't find official link.
Here is what I've got for https://eips.ethereum.org/EIPS/eip-6093#erc-721:
[
{
"inputs": [
{
"internalType": "address",
"name": "sender",
"type": "address"
},
{
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
},
{
"internalType": "address",
"name": "owner",
"type": "address"
}
],
"name": "ERC721IncorrectOwner",
"type": "error"
},
{
"inputs": [
{
"internalType": "address",
"name": "operator",
"type": "address"
},
{
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
}
],
"name": "ERC721InsufficientApproval",
"type": "error"
},
{
"inputs": [
{
"internalType": "address",
"name": "approver",
"type": "address"
}
],
"name": "ERC721InvalidApprover",
"type": "error"
},
{
"inputs": [
{
"internalType": "address",
"name": "operator",
"type": "address"
}
],
"name": "ERC721InvalidOperator",
"type": "error"
},
{
"inputs": [
{
"internalType": "address",
"name": "owner",
"type": "address"
}
],
"name": "ERC721InvalidOwner",
"type": "error"
},
{
"inputs": [
{
"internalType": "address",
"name": "receiver",
"type": "address"
}
],
"name": "ERC721InvalidReceiver",
"type": "error"
},
{
"inputs": [
{
"internalType": "address",
"name": "sender",
"type": "address"
}
],
"name": "ERC721InvalidSender",
"type": "error"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
}
],
"name": "ERC721NonexistentToken",
"type": "error"
}
]
Ok, so this library is almost 10 years old. While most of the code has been rewritten over time, some of the weird logic remains. You correctly pointed out that calling the client instead of the contract is not intuitive. This mechanic requires some time and as this is only touching the developer experience, this would be for me very low priority given all the other open issues. I'd be happy to accept PRs though, if anyone wants to give this a go.
W.r.t. ERC-6093 custom errors, this is a worthwhile endeavor. I'll take a look at it but cannot promise any fast progress in the coming weeks. I'm currently focusing, if anything on finally fixing the ABI coders.
Closed by #344