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: improve decode error #831

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
1fc6cd6
fix: init Web3HttpProvider without chain id
zhangliugang Apr 18, 2023
d098b87
Merge commit 'dab2667578b9d053fc82dadb89337f5cbdd94f38' into develop
zhangliugang Aug 22, 2023
edd8e72
feat: improve decode error
zhangliugang Aug 28, 2023
9a7212e
fix: checkError
zhangliugang Aug 28, 2023
25dc43c
Trim Trailing Whitespace
zhangliugang Aug 28, 2023
5c3652b
fix eth method
zhangliugang Aug 28, 2023
20bfa7a
add missing test contract bytecode
zhangliugang Aug 28, 2023
acd66fe
resolve reviews suggestions
zhangliugang Sep 2, 2023
4521eb2
fix codespell
zhangliugang Sep 2, 2023
eb32f20
update function documentation
zhangliugang Sep 3, 2023
bc6f621
chore: error message update
JeneaVranceanu Nov 26, 2023
45c8ba5
chore: code reordering
JeneaVranceanu Nov 26, 2023
e542974
chore: docs refactoring
JeneaVranceanu Nov 26, 2023
f00a88f
chore: docs refactoring
JeneaVranceanu Nov 26, 2023
9dc9713
chore: comment refactoring
JeneaVranceanu Nov 26, 2023
62addff
chore: missing space added
JeneaVranceanu Nov 26, 2023
71e8902
chore: avoiding try? in tests (try is more preffered)
JeneaVranceanu Nov 26, 2023
3cadddd
fix: isHex string extension function refactoring
JeneaVranceanu Nov 26, 2023
01e1f6a
chore: isHex string extension tests
JeneaVranceanu Nov 26, 2023
e66dd35
Merge branch 'develop' into feat/decode-error
JeneaVranceanu Nov 26, 2023
0ba7039
chore: error messages updated
JeneaVranceanu Nov 26, 2023
ab90bda
Merge pull request #1 from JeneaVranceanu/feat/decode-error
zhangliugang Nov 27, 2023
7e4fdc1
fix: code spell
zhangliugang Nov 27, 2023
d4f6678
fix: testDecodeMulticallCopy
zhangliugang Nov 27, 2023
56fc498
chore: Update Sources/Web3Core/EthereumNetwork/Request/APIRequest+Met…
JeneaVranceanu Jan 8, 2024
084a2cb
chore: Update Sources/Web3Core/EthereumNetwork/Request/APIRequest+Met…
JeneaVranceanu Jan 8, 2024
e6ff991
chore: added docs; new test for ABI.Element.Function;
JeneaVranceanu Jan 8, 2024
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
69 changes: 62 additions & 7 deletions Sources/Web3Core/Contract/ContractProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,12 @@ public protocol ContractProtocol {
/// - name with arguments:`myFunction(uint256)`.
/// - method signature (with or without `0x` prefix, case insensitive): `0xFFffFFff`;
/// - data: non empty bytes to decode;
/// - Returns: dictionary with decoded values. `nil` if decoding failed.
func decodeReturnData(_ method: String, data: Data) -> [String: Any]?
/// - Returns: dictionary with decoded values.
/// - Throws:
/// - `Web3Error.revert(String, String?)` when function call aborted by `revert(string)` and `require(expression, string)`.
/// - `Web3Error.revertCustom(String, Dictionary)` when function call aborted by `revert CustomError()`.
@discardableResult
func decodeReturnData(_ method: String, data: Data) throws -> [String: Any]

/// Decode input arguments of a function.
/// - Parameters:
Expand Down Expand Up @@ -320,13 +324,40 @@ extension DefaultContractProtocol {
return bloom.test(topic: event.topic)
}

public func decodeReturnData(_ method: String, data: Data) -> [String: Any]? {
@discardableResult
public func decodeReturnData(_ method: String, data: Data) throws -> [String: Any] {
if method == "fallback" {
return [String: Any]()
return [:]
}

guard let function = methods[method]?.first else {
throw Web3Error.inputError(desc: "Make sure ABI you use contains '\(method)' method.")
}

switch data.count % 32 {
case 0:
return try function.decodeReturnData(data)
case 4:
let selector = data[0..<4]
if selector.toHexString() == "08c379a0", let reason = ABI.Element.EthError.decodeStringError(data[4...]) {
throw Web3Error.revert("revert(string)` or `require(expression, string)` was executed. reason: \(reason)", reason: reason)
}
else if selector.toHexString() == "4e487b71", let reason = ABI.Element.EthError.decodePanicError(data[4...]) {
let panicCode = String(format: "%02X", Int(reason)).addHexPrefix()
throw Web3Error.revert("Error: call revert exception; VM Exception while processing transaction: reverted with panic code \(panicCode)", reason: panicCode)
}
else if let customError = errors[selector.toHexString().addHexPrefix().lowercased()] {
if let errorArgs = customError.decodeEthError(data[4...]) {
throw Web3Error.revertCustom(customError.signature, errorArgs)
} else {
throw Web3Error.inputError(desc: "Signature matches \(customError.errorDeclaration) but failed to be decoded.")
}
} else {
throw Web3Error.inputError(desc: "Make sure ABI you use contains error that can match signature: 0x\(selector.toHexString())")
}
default:
throw Web3Error.inputError(desc: "Given data has invalid bytes count.")
}
return methods[method]?.compactMap({ function in
return function.decodeReturnData(data)
}).first
}

public func decodeInputData(_ method: String, data: Data) -> [String: Any]? {
Expand All @@ -346,8 +377,32 @@ extension DefaultContractProtocol {
return function.decodeInputData(Data(data[data.startIndex + 4 ..< data.startIndex + data.count]))
}

public func decodeEthError(_ data: Data) -> [String: Any]? {
guard data.count >= 4,
let err = errors.first(where: { $0.value.methodEncoding == data[0..<4] })?.value else {
return nil
}
return err.decodeEthError(data[4...])
}

public func getFunctionCalled(_ data: Data) -> ABI.Element.Function? {
guard data.count >= 4 else { return nil }
return methods[data[data.startIndex ..< data.startIndex + 4].toHexString().addHexPrefix()]?.first
}
}

extension DefaultContractProtocol {
@discardableResult
public func callStatic(_ method: String, parameters: [Any], provider: Web3Provider) async throws -> [String: Any] {
guard let address = address else {
throw Web3Error.inputError(desc: "RPC failed: contract is missing an address.")
}
guard let data = self.method(method, parameters: parameters, extraData: nil) else {
throw Web3Error.dataError
}
let transaction = CodableTransaction(to: address, data: data)

let result: Data = try await APIRequest.sendRequest(with: provider, for: .call(transaction, .latest)).result
return try decodeReturnData(method, data: result)
}
}
101 changes: 52 additions & 49 deletions Sources/Web3Core/EthereumABI/ABIElements.swift
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ extension ABI.Element.Constructor {
extension ABI.Element.Function {

/// Encode parameters of a given contract method
/// - Parameter parameters: Parameters to pass to Ethereum contract
/// - Parameters: Parameters to pass to Ethereum contract
/// - Returns: Encoded data
public func encodeParameters(_ parameters: [Any]) -> Data? {
guard parameters.count == inputs.count,
Expand Down Expand Up @@ -292,6 +292,44 @@ extension ABI.Element.Event {
}
}

// MARK: - Decode custom error

extension ABI.Element.EthError {
/// Decodes `revert CustomError(_)` calls.
/// - Parameters:
/// - data: bytes returned by a function call that stripped error signature hash.
/// - Returns: a dictionary containing decoded data mappend to indices and names of returned values or nil if decoding failed.
public func decodeEthError(_ data: Data) -> [String: Any]? {
guard inputs.count * 32 <= data.count,
let decoded = ABIDecoder.decode(types: inputs, data: data) else {
return nil
}

var result = [String: Any]()
for (index, out) in inputs.enumerated() {
result["\(index)"] = decoded[index]
if !out.name.isEmpty {
result[out.name] = decoded[index]
}
}
return result
}

/// Decodes `revert(string)` or `require(expression, string)` calls.
/// These calls are decomposed as `Error(string)` error.
public static func decodeStringError(_ data: Data) -> String? {
let decoded = ABIDecoder.decode(types: [.init(name: "", type: .string)], data: data)
return decoded?.first as? String
}

/// Decodes `Panic(uint256)` errors.
/// See more about panic code explain at: https://docs.soliditylang.org/en/v0.8.21/control-structures.html#panic-via-assert-and-error-via-require
public static func decodePanicError(_ data: Data) -> BigUInt? {
let decoded = ABIDecoder.decode(types: [.init(name: "", type: .uint(bits: 256))], data: data)
return decoded?.first as? BigUInt
}
}

// MARK: - Function input/output decoding

extension ABI.Element {
Expand All @@ -304,7 +342,7 @@ extension ABI.Element {
case .fallback:
return nil
case .function(let function):
return function.decodeReturnData(data)
return try? function.decodeReturnData(data)
case .receive:
return nil
case .error:
Expand Down Expand Up @@ -337,74 +375,38 @@ extension ABI.Element.Function {
return ABIDecoder.decodeInputData(rawData, methodEncoding: methodEncoding, inputs: inputs)
}

/// Decodes data returned by a function call. Able to decode `revert(string)`, `revert CustomError(...)` and `require(expression, string)` calls.
/// Decodes data returned by a function call.
/// - Parameters:
/// - data: bytes returned by a function call;
/// - errors: optional dictionary of known errors that could be returned by the function you called. Used to decode the error information.
/// - Returns: a dictionary containing decoded data mappend to indices and names of returned values if these are not `nil`.
/// If `data` is an error response returns dictionary containing all available information about that specific error. Read more for details.
/// - Throws:
/// - `Web3Error.processingError(desc: String)` when decode process failed.
///
/// Return cases:
/// - when no `outputs` declared and `data` is not an error response:
/// - when no `outputs` declared:
/// ```swift
/// ["_success": true]
/// [:]
/// ```
/// - when `outputs` declared and decoding completed successfully:
/// ```swift
/// ["_success": true, "0": value_1, "1": value_2, ...]
/// ["0": value_1, "1": value_2, ...]
/// ```
/// Additionally this dictionary will have mappings to output names if these names are specified in the ABI;
/// - function call was aborted using `revert(message)` or `require(expression, message)`:
/// ```swift
/// ["_success": false, "_abortedByRevertOrRequire": true, "_errorMessage": message]`
/// ```
/// - function call was aborted using `revert CustomMessage()` and `errors` argument contains the ABI of that custom error type:
/// ```swift
/// ["_success": false,
/// "_abortedByRevertOrRequire": true,
/// "_error": error_name_and_types, // e.g. `MyCustomError(uint256, address senderAddress)`
/// "0": error_arg1,
/// "1": error_arg2,
/// ...,
/// "error_arg1_name": error_arg1, // Only named arguments will be mapped to their names, e.g. `"senderAddress": EthereumAddress`
/// "error_arg2_name": error_arg2, // Otherwise, you can query them by position index.
/// ...]
/// ```
/// - in case of any error:
/// ```swift
/// ["_success": false, "_failureReason": String]
/// ```
/// Error reasons include:
/// - `outputs` declared but at least one value failed to be decoded;
/// - `data.count` is less than `outputs.count * 32`;
/// - `outputs` defined and `data` is empty;
/// - `data` represent reverted transaction
///
/// How `revert(string)` and `require(expression, string)` return value is decomposed:
/// - `08C379A0` function selector for `Error(string)`;
/// - next 32 bytes are the data offset;
/// - next 32 bytes are the error message length;
/// - the next N bytes, where N >= 32, are the message bytes
/// - the rest are 0 bytes padding.
public func decodeReturnData(_ data: Data, errors: [String: ABI.Element.EthError]? = nil) -> [String: Any] {
if let decodedError = decodeErrorResponse(data, errors: errors) {
JeneaVranceanu marked this conversation as resolved.
Show resolved Hide resolved
return decodedError
}

public func decodeReturnData(_ data: Data) throws -> [String: Any] {
JeneaVranceanu marked this conversation as resolved.
Show resolved Hide resolved
guard !outputs.isEmpty else {
NSLog("Function doesn't have any output types to decode given data.")
return ["_success": true]
return [:]
}

guard outputs.count * 32 <= data.count else {
return ["_success": false, "_failureReason": "Bytes count must be at least \(outputs.count * 32). Given \(data.count). Decoding will fail."]
throw Web3Error.processingError(desc: "Bytes count must be at least \(outputs.count * 32). Given \(data.count). Decoding will fail.")
}

// TODO: need improvement - we should be able to tell which value failed to be decoded
guard let values = ABIDecoder.decode(types: outputs, data: data) else {
return ["_success": false, "_failureReason": "Failed to decode at least one value."]
throw Web3Error.processingError(desc: "Failed to decode at least one value.")
}
var returnArray: [String: Any] = ["_success": true]
var returnArray: [String: Any] = [:]
for i in outputs.indices {
returnArray["\(i)"] = values[i]
if !outputs[i].name.isEmpty {
Expand Down Expand Up @@ -453,6 +455,7 @@ extension ABI.Element.Function {
/// // "_parsingError" is optional and is present only if decoding of custom error arguments failed
/// "_parsingError": "Data matches MyCustomError(uint256, address senderAddress) but failed to be decoded."]
/// ```
@available(*, deprecated, message: "Use decode function from `ABI.Element.EthError` instead")
public func decodeErrorResponse(_ data: Data, errors: [String: ABI.Element.EthError]? = nil) -> [String: Any]? {
/// If data is empty and outputs are expected it is treated as a `require(expression)` or `revert()` call with no message.
/// In solidity `require(false)` and `revert()` calls return empty error response.
Expand Down
50 changes: 49 additions & 1 deletion Sources/Web3Core/EthereumABI/ABIParameterTypes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -168,31 +168,79 @@ extension ABI.Element.ParameterType: Equatable {
}

extension ABI.Element.Function {
/// String representation of a function, e.g. `transfer(address,uint256)`.
public var signature: String {
return "\(name ?? "")(\(inputs.map { $0.type.abiRepresentation }.joined(separator: ",")))"
}

/// Function selector, e.g. `"cafe1234"`. Without hex prefix `0x`.
@available(*, deprecated, renamed: "selector", message: "Please, use 'selector' property instead.")
public var methodString: String {
return selector
}

/// Function selector, e.g. `"cafe1234"`. Without hex prefix `0x`.
public var selector: String {
return String(signature.sha3(.keccak256).prefix(8))
}

/// Function selector (e.g. `0xcafe1234`) but as raw bytes.
@available(*, deprecated, renamed: "selectorEncoded", message: "Please, use 'selectorEncoded' property instead.")
public var methodEncoding: Data {
return signature.data(using: .ascii)!.sha3(.keccak256)[0...3]
return selectorEncoded
}

/// Function selector (e.g. `0xcafe1234`) but as raw bytes.
public var selectorEncoded: Data {
return Data.fromHex(selector)!
}
}

// MARK: - Event topic
extension ABI.Element.Event {
/// String representation of an event, e.g. `ContractCreated(address)`.
public var signature: String {
return "\(name)(\(inputs.map { $0.type.abiRepresentation }.joined(separator: ",")))"
}

/// Hashed signature of an event, e.g. `0xcf78cf0d6f3d8371e1075c69c492ab4ec5d8cf23a1a239b6a51a1d00be7ca312`.
public var topic: Data {
return signature.data(using: .ascii)!.sha3(.keccak256)
}
}

extension ABI.Element.EthError {
/// String representation of an error, e.g. `TrasferFailed(address)`.
public var signature: String {
return "\(name)(\(inputs.map { $0.type.abiRepresentation }.joined(separator: ",")))"
}

/// Error selector, e.g. `"cafe1234"`. Without hex prefix `0x`.
@available(*, deprecated, renamed: "selector", message: "Please, use 'selector' property instead.")
public var methodString: String {
Copy link
Collaborator

Choose a reason for hiding this comment

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

It looks like this could be named selector as this is the terminology used in Solidity.
I see that methodString should be rather something like ErrorXyz(uint256,...) and signature should be 0xcafe1234. The same applies for all other types e.g. Function, Event etc.

For now, no action here is required. I'll take a look at the terminology used in Solidity and then we can think about renaming some of our variables.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

or leave the signature unchanged, add a new signatureHash for hash, mark methodEncoding and methodString as deprecated

return selector
}

/// Error selector, e.g. `"cafe1234"`. Without hex prefix `0x`.
public var selector: String {
return String(signature.sha3(.keccak256).prefix(8))
}

/// Error selector (e.g. `0xcafe1234`) but as raw bytes.
@available(*, deprecated, renamed: "selectorEncoded", message: "Please, use 'selectorEncoded' property instead.")
public var methodEncoding: Data {
return selectorEncoded
}

/// Error selector (e.g. `0xcafe1234`) but as raw bytes.
public var selectorEncoded: Data {
return Data.fromHex(selector)!
}
}

extension ABI.Element.ParameterType: ABIEncoding {

/// Returns a valid solidity type like `address`, `uint128` or any other built-in type from Solidity.
public var abiRepresentation: String {
switch self {
case .uint(let bits):
Expand Down
2 changes: 2 additions & 0 deletions Sources/Web3Core/EthereumABI/Sequence+ABIExtension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ public extension Sequence where Element == ABI.Element {
var errors = [String: ABI.Element.EthError]()
for case let .error(error) in self {
errors[error.name] = error
errors[error.signature] = error
errors[error.methodString.addHexPrefix().lowercased()] = error
}
return errors
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,12 @@
import Foundation

extension APIRequest {
var method: REST {
public var method: REST {
.POST
}

public var encodedBody: Data {
let request = RequestBody(method: call, params: parameters)
// this is safe to force try this here
// Because request must failed to compile if it not conformable with `Encodable` protocol
return try! JSONEncoder().encode(request)
public var encodedBody: Data {
RequestBody(method: call, params: parameters).encodedBody
}

var parameters: [RequestParameter] {
Expand Down
Loading