Skip to content

Commit 2630321

Browse files
committed
Add to_json() to contract ABI objects
1 parent 7bd443d commit 2630321

4 files changed

Lines changed: 252 additions & 0 deletions

File tree

docs/changelog.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Added
1616

1717
- Support for contract errors with anonymous fields. (PR_90_)
1818
- Support for partially named method arguments. (PR_91_)
19+
- ``to_json()`` methods in contract ABI objects. (PR_93_)
1920

2021

2122
Fixed
@@ -27,6 +28,7 @@ Fixed
2728
.. _PR_90: https://github.com/fjarri-eth/pons/pull/90
2829
.. _PR_91: https://github.com/fjarri-eth/pons/pull/91
2930
.. _PR_92: https://github.com/fjarri-eth/pons/pull/92
31+
.. _PR_93: https://github.com/fjarri-eth/pons/pull/93
3032

3133

3234
0.10.0 (2025-10-19)

pons/_contract_abi.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,18 @@ def decode(self, value_bytes: bytes) -> FieldValues:
173173
"""
174174
return FieldValues(list(zip(self.names, decode_args(self.types, value_bytes), strict=True)))
175175

176+
def to_json(self) -> ABI_JSON:
177+
"""Returns this object's JSON ABI."""
178+
args = []
179+
for name, tp in zip(self.names, self.types, strict=True):
180+
args.append(
181+
{
182+
"name": name if name is not None else "",
183+
"type": tp.canonical_form,
184+
}
185+
)
186+
return args
187+
176188
def __str__(self) -> str:
177189
fields = ", ".join(
178190
tp.canonical_form + ((" " + name) if name is not None else "")
@@ -297,6 +309,19 @@ def decode_log_entry(self, topics: Sequence[bytes], data: bytes) -> FieldValues:
297309

298310
return FieldValues(decoded_data)
299311

312+
def to_json(self) -> ABI_JSON:
313+
"""Returns this object's JSON ABI."""
314+
args: list[ABI_JSON] = []
315+
for name, tp, indexed in zip(self.names, self.types, self.indexed, strict=True):
316+
args.append(
317+
{
318+
"indexed": indexed,
319+
"name": name if name is not None else "",
320+
"type": tp.canonical_form,
321+
}
322+
)
323+
return args
324+
300325
def __str__(self) -> str:
301326
params = []
302327
for name, tp, indexed in zip(self.names, self.types, self.indexed, strict=True):
@@ -359,6 +384,14 @@ def __call__(self, *args: Any, **kwargs: Any) -> "ConstructorCall":
359384
input_bytes = self.inputs.encode(self._inputs_signature.bind(*args, **kwargs).args)
360385
return ConstructorCall(input_bytes)
361386

387+
def to_json(self) -> ABI_JSON:
388+
"""Returns this object's JSON ABI."""
389+
return {
390+
"type": "constructor",
391+
"stateMutability": "payable" if self.payable else "nonpayable",
392+
"inputs": self.inputs.to_json(),
393+
}
394+
362395
def __str__(self) -> str:
363396
return f"constructor{self.inputs} " + ("payable" if self.payable else "nonpayable")
364397

@@ -515,6 +548,16 @@ def with_method(self, method: "Method") -> "MultiMethod":
515548
"""Returns a multimethod resulting from joining this method with `method`."""
516549
return MultiMethod(self, method)
517550

551+
def to_json(self) -> ABI_JSON:
552+
"""Returns this object's JSON ABI."""
553+
return {
554+
"type": "function",
555+
"name": self.name,
556+
"stateMutability": self._mutability.value,
557+
"inputs": self.inputs.to_json(),
558+
"outputs": self.outputs.to_json(),
559+
}
560+
518561
def __str__(self) -> str:
519562
returns = "" if not self.outputs.names else f" returns {self.outputs}"
520563
return f"function {self.name}{self.inputs} {self._mutability.value}{returns}"
@@ -586,6 +629,10 @@ def __call__(self, *args: Any, **kwds: Any) -> "MethodCall":
586629

587630
raise TypeError("Could not find a suitable overloaded method for the given arguments")
588631

632+
def to_json(self) -> list[ABI_JSON]:
633+
"""Returns this object's JSON ABI."""
634+
return [method.to_json() for method in self._methods.values()]
635+
589636
def __str__(self) -> str:
590637
return "; ".join(str(method) for method in self._methods.values())
591638

@@ -689,6 +736,15 @@ def decode_log_entry(self, log_entry: LogEntry) -> FieldValues:
689736

690737
return self.fields.decode_log_entry([bytes(topic) for topic in topics], log_entry.data)
691738

739+
def to_json(self) -> ABI_JSON:
740+
"""Returns this object's JSON ABI."""
741+
return {
742+
"type": "event",
743+
"name": self.name,
744+
"inputs": self.fields.to_json(),
745+
"anonymous": self.anonymous,
746+
}
747+
692748
def __str__(self) -> str:
693749
return f"event {self.name}{self.fields}" + (" anonymous" if self.anonymous else "")
694750

@@ -743,6 +799,14 @@ def decode_fields(self, data_bytes: bytes) -> FieldValues:
743799
"""Decodes the error fields from the given packed data."""
744800
return self.fields.decode(data_bytes)
745801

802+
def to_json(self) -> ABI_JSON:
803+
"""Returns this object's JSON ABI."""
804+
return {
805+
"type": "error",
806+
"name": self.name,
807+
"inputs": self.fields.to_json(),
808+
}
809+
746810
def __str__(self) -> str:
747811
return f"error {self.name}{self.fields}"
748812

@@ -773,6 +837,13 @@ def from_json(cls, method_entry: ABI_JSON) -> "Fallback":
773837
def __init__(self, *, payable: bool = False):
774838
self.payable = payable
775839

840+
def to_json(self) -> ABI_JSON:
841+
"""Returns this object's JSON ABI."""
842+
return {
843+
"type": "fallback",
844+
"stateMutability": "payable" if self.payable else "nonpayable",
845+
}
846+
776847
def __str__(self) -> str:
777848
return "fallback() " + ("payable" if self.payable else "nonpayable")
778849

@@ -801,6 +872,13 @@ def from_json(cls, method_entry: ABI_JSON) -> "Receive":
801872
def __init__(self, *, payable: bool = False):
802873
self.payable = payable
803874

875+
def to_json(self) -> ABI_JSON:
876+
"""Returns this object's JSON ABI."""
877+
return {
878+
"type": "receive",
879+
"stateMutability": "payable" if self.payable else "nonpayable",
880+
}
881+
804882
def __str__(self) -> str:
805883
return "receive() " + ("payable" if self.payable else "nonpayable")
806884

@@ -996,6 +1074,27 @@ def resolve_error(self, error_data: bytes) -> tuple[Error, FieldValues]:
9961074

9971075
raise UnknownError(f"Could not find an error with selector {selector.hex()} in the ABI")
9981076

1077+
def to_json(self) -> ABI_JSON:
1078+
"""Returns the serialized list of contract items (methods, errors, events)."""
1079+
all_items: Iterable[
1080+
Constructor | Fallback | Receive | Method | MultiMethod | Event | Error
1081+
] = chain(
1082+
[self.constructor] if self.constructor else [],
1083+
[self.fallback] if self.fallback else [],
1084+
[self.receive] if self.receive else [],
1085+
self.method,
1086+
self.event,
1087+
self.error,
1088+
)
1089+
entries: list[ABI_JSON] = []
1090+
for item in all_items:
1091+
if isinstance(item, MultiMethod):
1092+
entries.extend(item.to_json())
1093+
else:
1094+
entries.append(item.to_json())
1095+
1096+
return entries
1097+
9991098
def __str__(self) -> str:
10001099
all_items: Iterable[
10011100
Constructor | Fallback | Receive | Method | MultiMethod | Event | Error

tests/TestContractABI.sol

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
pragma solidity >=0.8.0 <0.9.0;
2+
3+
4+
// A contract with various possible edge cases to test the conversion to/from JSON
5+
contract RoundTrip {
6+
// A public field will generate a getter method
7+
uint256 public field;
8+
9+
constructor(uint256 x) payable {
10+
field = x;
11+
}
12+
13+
receive() external payable {
14+
}
15+
16+
// Note that the argument and the return type will not be present
17+
// in the JSON ABI returned by `solc`.
18+
fallback(bytes calldata) external payable returns (bytes memory) {
19+
}
20+
21+
// Regular methods
22+
23+
function pureMethod(uint256 y) public view returns (uint256) {
24+
revert();
25+
}
26+
27+
function viewMethod(uint256 y) public view returns (uint256) {
28+
revert();
29+
}
30+
31+
function nonpayableMethod(uint256 y) public returns (uint256) {
32+
revert();
33+
}
34+
35+
function payableMethod(uint256 y) public payable returns (uint256) {
36+
revert();
37+
}
38+
39+
// A method with some unnamed arguments
40+
41+
function unnamedArgsMethod(uint256, address) public view returns (uint256) {
42+
revert();
43+
}
44+
45+
function partiallyNamedArgsMethod(uint256 y, address) public view returns (uint256) {
46+
revert();
47+
}
48+
49+
function partiallyNamedArgsAndReturnsMethod(uint256, address y) public view returns (uint256 z, uint256) {
50+
revert();
51+
}
52+
53+
// Overloaded method
54+
55+
function overloadedMethod(uint256 x) public view returns (uint256) {
56+
revert();
57+
}
58+
59+
function overloadedMethod(uint256 x, address y) public view returns (uint256) {
60+
revert();
61+
}
62+
63+
// Event with some fields indexed
64+
event regularEvent(
65+
uint32 indexed x,
66+
uint256 y
67+
);
68+
69+
// Anonymous event
70+
event anonymousEvent(
71+
uint32 x,
72+
uint256 indexed y
73+
) anonymous;
74+
75+
// An event with partially named fields
76+
event partiallyNamedFieldsEvent(
77+
uint32 x,
78+
uint256,
79+
uint32 indexed y,
80+
uint256 indexed
81+
);
82+
83+
// Error with all fields named
84+
error regularError(uint256 x, address y);
85+
86+
// Errors with some unnamed fields
87+
88+
error unnamedFieldsError(uint256, address);
89+
90+
error partiallyNamedFieldsError(uint256, address y);
91+
}

tests/test_contract_abi.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import re
2+
from copy import deepcopy
3+
from pathlib import Path
24

35
import pytest
6+
import solcx
47
from ethereum_rpc import Address, BlockHash, LogEntry, LogTopic, TxHash, keccak
58

69
from pons import (
@@ -546,6 +549,63 @@ def test_contract_abi_json() -> None:
546549
assert isinstance(cabi.error.CustomError, Error)
547550

548551

552+
def clean_entries(abi: list[ABI_JSON]) -> list[ABI_JSON]:
553+
"""
554+
Makes JSON ABI entries comparable.
555+
556+
Currently just removes `internalType` items (we do not support keeping them at the moment).
557+
"""
558+
abi = deepcopy(abi)
559+
for entry in abi:
560+
assert isinstance(entry, dict)
561+
if "inputs" in entry:
562+
for arg in entry["inputs"]:
563+
if "internalType" in arg:
564+
del arg["internalType"]
565+
if "outputs" in entry:
566+
for arg in entry["outputs"]:
567+
if "internalType" in arg:
568+
del arg["internalType"]
569+
570+
return abi
571+
572+
573+
def test_json_roundtrip() -> None:
574+
# Since we need the raw JSON ABI, we cannot use the existing compiler function
575+
# (which wraps the ABI in `ContractABI` straight away).
576+
577+
compiled = solcx.compile_files(
578+
[Path(__file__).resolve().parent / "TestContractABI.sol"],
579+
output_values=["abi"],
580+
)
581+
582+
results = {}
583+
for identifier, compiled_contract in compiled.items():
584+
_path, contract_name = identifier.split(":")
585+
results[contract_name] = compiled_contract["abi"]
586+
587+
json_abi = results["RoundTrip"]
588+
589+
cabi = ContractABI.from_json(json_abi)
590+
591+
assert isinstance(json_abi, list)
592+
json_abi_ref = clean_entries(json_abi)
593+
594+
json_abi_test = cabi.to_json()
595+
assert isinstance(json_abi_test, list)
596+
json_abi_test = clean_entries(json_abi_test)
597+
598+
# The comparison is a little tricky:
599+
# - ABI entries are a list, and the order may have changed after the roundtrip
600+
# - There will be multiple entries with the same name for overloaded methods
601+
602+
assert len(json_abi_ref) == len(json_abi_test)
603+
604+
for ref_entry in json_abi_ref:
605+
matches = [entry for entry in json_abi_test if entry == ref_entry]
606+
assert len(matches) == 1, "expected one and only one match for each entry"
607+
608+
549609
def test_contract_abi_init() -> None:
550610
cabi = ContractABI(
551611
constructor=Constructor(inputs=dict(a=abi.uint(8), b=abi.bool), payable=True),

0 commit comments

Comments
 (0)