diff --git a/docs/changelog.rst b/docs/changelog.rst index 7d3c23b..d983405 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -5,6 +5,12 @@ Changelog 0.7.1 (unreleased) ~~~~~~~~~~~~~~~~~~ +Added +^^^^^ + +- `Client.transact()` takes an optional `return_events` argument, allowing one to get "return values" from the transaction via events. (PR_52_) + + Fixed ^^^^^ @@ -12,6 +18,7 @@ Fixed .. _PR_51: https://github.com/fjarri/pons/pull/51 +.. _PR_52: https://github.com/fjarri/pons/pull/52 0.7.0 (09-07-2023) diff --git a/pons/_client.py b/pons/_client.py index 3fb0748..4c49ae6 100644 --- a/pons/_client.py +++ b/pons/_client.py @@ -10,6 +10,7 @@ Dict, List, Tuple, + Sequence, Callable, Mapping, TypeVar, @@ -36,6 +37,7 @@ BoundConstructorCall, BoundMethodCall, BoundEventFilter, + BoundEvent, ) from ._contract_abi import EventFilter, PANIC_ERROR, LEGACY_ERROR, UnknownError, ContractABI, Error from ._provider import ( @@ -697,13 +699,19 @@ async def transact( call: BoundMethodCall, amount: Amount = Amount(0), gas: Optional[int] = None, - ) -> None: + return_events: Optional[Sequence[BoundEvent]] = None, + ) -> Dict[BoundEvent, List[Dict[str, Any]]]: """ Transacts with the contract using a prepared method call. If ``gas`` is ``None``, the required amount of gas is estimated first, otherwise the provided value is used. Waits for the transaction to be confirmed. + If any bound events are given in `return_events`, the provider will be queried + for any firing of these events originating from the hash of the completed transaction + (from the contract addresses the events are bound to), + and the results will be returned as a dictionary keyed by the corresponding event object. + Raises :py:class:`TransactionFailed` if the transaction was submitted successfully, but could not be processed. If gas estimation is run, see the additional errors that may be raised in the docs for @@ -714,6 +722,35 @@ async def transact( if not receipt.succeeded: raise TransactionFailed(f"Transact failed (receipt: {receipt})") + if return_events is None: + return {} + + results = {} + for event in return_events: + event_filter = event() + log_filter = await self.eth_new_filter( + source=event_filter.contract_address, + event_filter=EventFilter(event_filter.topics), + from_block=receipt.block_number, + to_block=receipt.block_number, + ) + log_entries = await self.eth_get_filter_changes(log_filter) + event_results = [] + for log_entry in log_entries: + # We can't ensure it statically, since `eth_getFilterChanges` return type depends + # on the filter passed to it. + log_entry = cast(LogEntry, log_entry) + + if log_entry.transaction_hash != receipt.transaction_hash: + continue + + decoded = event_filter.decode_log_entry(log_entry) + event_results.append(decoded) + + results[event] = event_results + + return results + @rpc_call("eth_newBlockFilter") async def eth_new_block_filter(self) -> BlockFilter: """ diff --git a/tests/TestClient.sol b/tests/TestClient.sol index 2f79f17..6b8d31e 100644 --- a/tests/TestClient.sol +++ b/tests/TestClient.sol @@ -47,8 +47,22 @@ contract BasicContract { function deposit2(bytes4 id) public payable { emit Deposit2(msg.sender, id, msg.value, msg.value + 1); } -} + event Event1( + uint32 indexed value + ); + + event Event2( + uint32 value + ); + + function emitMultipleEvents(uint32 x) public { + emit Event1(x); + emit Event1(x + 1); + emit Event2(x + 2); + emit Event2(x + 3); + } +} contract PayableConstructor { uint256 public state; diff --git a/tests/test_client.py b/tests/test_client.py index 37b01f8..463d1f5 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -360,6 +360,61 @@ async def test_transact(test_provider, session, compiled_contracts, root_signer) await session.transact(root_signer, deployed_contract.method.faultySetState(0), gas=300000) +async def test_transact_and_return_events( + autojump_clock, test_provider, session, compiled_contracts, root_signer, another_signer +): + await session.transfer(root_signer, another_signer.address, Amount.ether(1)) + + basic_contract = compiled_contracts["BasicContract"] + + deployed_contract = await session.deploy(root_signer, basic_contract.constructor(123)) + + Event1 = deployed_contract.event.Event1 + Event2 = deployed_contract.event.Event2 + + results_for = lambda x: { + Event1: [{"value": x}, {"value": x + 1}], + Event2: [{"value": x + 2}, {"value": x + 3}], + } + + # Normal operation: one relevant transaction in the block + + x = 1 + result = await session.transact( + root_signer, + deployed_contract.method.emitMultipleEvents(x), + return_events=[Event1, Event2], + ) + assert result == results_for(x) + + # Two transactions for the same method in the same block - + # we need to be able to only pick up the results from the relevant transaction receipt + + test_provider.disable_auto_mine_transactions() + + results = {} + + async def transact(signer, x): + result = await session.transact( + signer, deployed_contract.method.emitMultipleEvents(x), return_events=[Event1, Event2] + ) + results[x] = result + + async def delayed_enable_mining(): + await trio.sleep(5) + test_provider.enable_auto_mine_transactions() + + x1 = 1 + x2 = 2 + async with trio.open_nursery() as nursery: + nursery.start_soon(transact, root_signer, x1) + nursery.start_soon(transact, another_signer, x2) + nursery.start_soon(delayed_enable_mining) + + assert results[x1] == results_for(x1) + assert results[x2] == results_for(x2) + + async def test_get_block(test_provider, session, root_signer, another_signer): to_transfer = Amount.ether(10) await session.transfer(root_signer, another_signer.address, to_transfer)