Repository: ethereum/web3.py

Issue:
New contract method execution option: generating the transaction dictionary
* Version: 4

### What was wrong?

There is no convenient way to prepare a contract transaction for offline signing.

Specifically, the prepared transaction should work smoothly with [`w3.eth.account.signTransaction()`](http://web3py.readthedocs.io/en/latest/web3.eth.account.html#web3.eth.account.Account.signTransaction).


### How can it be fixed?

#### New contract method preparation API

Add a new way to prepare a contract function transaction. It would look something like:

```
# new API
transaction_dict = myContract.functions.myMethod().asdict()

# existing APIs
signed = w3.eth.account.signTransaction(transaction_dict, private_key)
w3.eth.sendRawTransaction(signed.rawTransaction)
```

`asdict` should accept a dictionary that defines transaction fields, just like call/transact/etc, and return an `AttributeDict`.

#### Fields included

Besides the natural fields, like `to` and `data`, this must also generate other fields like:

- nonce
- chainId
- gas
- gasPrice

If they are not supplied as arguments to `asdict()`, then they should be inferred before returning. `signTransaction` will not attempt to infer these values, so they must be explicitly added.

#### API Naming

`asdict()` is okay, but isn't necessarily the obvious winner.

Some alternatives:

- `myContract.functions.myMethod().todict()`
- `myContract.functions.myMethod().tofields()`
- `myContract.functions.myMethod().prepare()`
- `myContract.functions.myMethod().generate()`

#### Crossover issue

Depending on whether #419 is already implemented, and how it's implemented, this might require a bit of extra work to expose `asdict` in both ways.

#### TODOs

- [x] choose naming
- [x] implement new API
- [x] document in `docs/`
- [x] reference this method in signTransaction docs `docs/web3.eth.account.rst`
~- [ ] Support for #419 - if implementation available~


Discussion:
suggest names with the word transaction in them

* `myContract.myMethod().prepareTransaction()`
* `myContract.myMethod().asTransactionDict()`
* `myContract.myMethod().generateTransaction()`
Yeah, I was feeling grumbly about how long it got. Especially when you add in the prep word (which I just realized I forgot to do. Now updated in the top.)

With prep word:

* `myContract.functions.myMethod().prepareTransaction()`
* `myContract.functions.myMethod().asTransactionDict()`
* `myContract.functions.myMethod().generateTransaction()`
Not sure I like it but worth considering offering a shortcut API as `myContract.f.myMethod().prepareTransaction()`.

Also, for methods that take no arguments, we should try to support `myContract.myMethod.call()`.
__This issue now has a funding of 1.0 ETH (329.52 USDT) attached to it.__  To view or claim this funding, [click here](https://gitcoin.co/funding/details?url=https://github.com/pipermerriam/web3.py/issues/430).
Hi, I'm willing to work on this :slightly_smiling_face:. I've done offline TX signing with web3.js but it had been a bit of a pain due to needed other libraries for TX signing. I have python experience so interested in how I can shape this 😃  Expect a merge request coming your way soon

Resolving patch:
diff --git a/web3/contract.py b/web3/contract.py
--- a/web3/contract.py
+++ b/web3/contract.py
@@ -77,6 +77,9 @@
     validate_abi,
     validate_address,
 )
+from web3.utils.transactions import (
+    fill_transaction_defaults,
+)
 
 
 DEPRECATED_SIGNATURE_MESSAGE = (
@@ -485,6 +488,49 @@ def __getattr__(self, function_name):
 
         return Transactor()
 
+    @combomethod
+    def buildTransaction(self, transaction=None):
+        """
+        Build the transaction dictionary without sending
+        """
+        if transaction is None:
+            built_transaction = {}
+        else:
+            built_transaction = dict(**transaction)
+
+        if 'data' in built_transaction:
+            raise ValueError("Cannot set data in call buildTransaction")
+
+        if isinstance(self, type) and 'to' not in built_transaction:
+            raise ValueError(
+                "When using `Contract.buildTransaction` from a contract factory "
+                "you must provide a `to` address with the transaction"
+            )
+        if not isinstance(self, type) and 'to' in built_transaction:
+            raise ValueError("Cannot set to in call buildTransaction")
+
+        if self.address:
+            built_transaction.setdefault('to', self.address)
+
+        if 'to' not in built_transaction:
+            raise ValueError(
+                "Please ensure that this contract instance has an address."
+            )
+
+        contract = self
+
+        class Caller(object):
+            def __getattr__(self, function_name):
+                callable_fn = functools.partial(
+                    build_transaction_for_function,
+                    contract,
+                    function_name,
+                    built_transaction,
+                )
+                return callable_fn
+
+        return Caller()
+
     #
     # Private Helpers
     #
@@ -699,7 +745,7 @@ def _none_addr(datatype, data):
 
 
 class ConciseMethod:
-    ALLOWED_MODIFIERS = set(['call', 'estimateGas', 'transact'])
+    ALLOWED_MODIFIERS = set(['call', 'estimateGas', 'transact', 'buildTransaction'])
 
     def __init__(self, contract, function):
         self.__contract = contract
@@ -820,3 +866,25 @@ def estimate_gas_for_function(contract=None,
 
     gas_estimate = contract.web3.eth.estimateGas(estimate_transaction)
     return gas_estimate
+
+
+def build_transaction_for_function(contract=None,
+                                   function_name=None,
+                                   transaction=None,
+                                   *args,
+                                   **kwargs):
+    """Builds a dictionary with the fields required to make the given transaction
+
+    Don't call this directly, instead use :meth:`Contract.buildTransaction`
+    on your contract instance.
+    """
+    prepared_transaction = contract._prepare_transaction(
+        fn_name=function_name,
+        fn_args=args,
+        fn_kwargs=kwargs,
+        transaction=transaction,
+    )
+
+    prepared_transaction = fill_transaction_defaults(contract.web3, prepared_transaction)
+
+    return prepared_transaction
diff --git a/web3/utils/transactions.py b/web3/utils/transactions.py
--- a/web3/utils/transactions.py
+++ b/web3/utils/transactions.py
@@ -59,10 +59,11 @@ def encode_transaction(unsigned_transaction, vrs):
 
 
 TRANSACTION_DEFAULTS = {
-    'gasPrice': lambda web3: web3.eth.gasPrice,
     'value': 0,
     'data': b'',
-    'chainId': lambda web3: int(web3.net.version),
+    'gas': lambda web3, tx: web3.eth.estimateGas(tx),
+    'gasPrice': lambda web3, tx: web3.eth.gasPrice,
+    'chainId': lambda web3, tx: int(web3.net.version),
 }
 
 TRANSACTION_FORMATTERS = {
@@ -97,7 +98,7 @@ def fill_transaction_defaults(web3, transaction):
         if key not in transaction:
             if callable(default_getter):
                 if web3 is not None:
-                    default_val = default_getter(web3)
+                    default_val = default_getter(web3, transaction)
                 else:
                     raise ValueError("You must specify %s in the transaction" % key)
             else:


Test patch:
diff --git a/tests/core/contracts/test_contract_buildTransaction.py b/tests/core/contracts/test_contract_buildTransaction.py
new file mode 100644
--- /dev/null
+++ b/tests/core/contracts/test_contract_buildTransaction.py
@@ -0,0 +1,124 @@
+# -*- coding: utf-8 -*-
+
+import pytest
+
+# Ignore warning in pyethereum 1.6 - will go away with the upgrade
+pytestmark = pytest.mark.filterwarnings("ignore:implicit cast from 'char *'")
+
+
+@pytest.fixture()
+def math_contract(web3, MathContract):
+    deploy_txn = MathContract.deploy()
+    deploy_receipt = web3.eth.getTransactionReceipt(deploy_txn)
+    assert deploy_receipt is not None
+    _math_contract = MathContract(address=deploy_receipt['contractAddress'])
+    return _math_contract
+
+
+def test_build_transaction_with_contract_no_arguments(web3, math_contract):
+    txn = math_contract.buildTransaction().increment()
+    assert txn == {
+        'to': math_contract.address,
+        'data': '0xd09de08a',
+        'value': 0,
+        'gas': 43120,
+        'gasPrice': 1,
+        'chainId': 1
+    }
+
+
+def test_build_transaction_with_contract_class_method(web3, MathContract, math_contract):
+    txn = MathContract.buildTransaction({'to': math_contract.address}).increment()
+    assert txn == {
+        'to': math_contract.address,
+        'data': '0xd09de08a',
+        'value': 0,
+        'gas': 43120,
+        'gasPrice': 1,
+        'chainId': 1
+    }
+
+
+def test_build_transaction_with_contract_default_account_is_set(web3, math_contract):
+    txn = math_contract.buildTransaction().increment()
+    assert txn == {
+        'to': math_contract.address,
+        'data': '0xd09de08a',
+        'value': 0,
+        'gas': 43120,
+        'gasPrice': 1,
+        'chainId': 1
+    }
+
+
+def test_build_transaction_with_contract_data_supplied_errors(web3, math_contract):
+    with pytest.raises(ValueError):
+        math_contract.buildTransaction({
+            'data': '0x000'
+        }).increment()
+
+
+def test_build_transaction_with_contract_to_address_supplied_errors(web3, math_contract):
+    with pytest.raises(ValueError):
+        math_contract.buildTransaction({
+            'to': '0xb2930B35844a230f00E51431aCAe96Fe543a0347'
+        }).increment()
+
+
+@pytest.mark.parametrize(
+    'transaction_args,method_args,method_kwargs,expected,skip_testrpc',
+    (
+        (
+            {}, (5,), {}, {
+                'data': '0x7cf5dab00000000000000000000000000000000000000000000000000000000000000005',  # noqa: E501
+                'value': 0, 'gas': 43242, 'gasPrice': 1, 'chainId': 1
+            }, False
+        ),
+        (
+            {'gas': 800000}, (5,), {}, {
+                'data': '0x7cf5dab00000000000000000000000000000000000000000000000000000000000000005',  # noqa: E501
+                'value': 0, 'gas': 800000, 'gasPrice': 1, 'chainId': 1
+            }, False
+        ),
+        (
+            {'gasPrice': 21000000000}, (5,), {}, {
+                'data': '0x7cf5dab00000000000000000000000000000000000000000000000000000000000000005',  # noqa: E501
+                'value': 0, 'gas': 43242, 'gasPrice': 21000000000, 'chainId': 1
+            }, False
+        ),
+        (
+            {'nonce': 7}, (5,), {}, {
+                'data': '0x7cf5dab00000000000000000000000000000000000000000000000000000000000000005',  # noqa: E501
+                'value': 0, 'gas': 43242, 'gasPrice': 1, 'nonce': 7, 'chainId': 1
+            }, True
+        ),
+        (
+            {'value': 20000}, (5,), {}, {
+                'data': '0x7cf5dab00000000000000000000000000000000000000000000000000000000000000005',  # noqa: E501
+                'value': 20000, 'gas': 43242, 'gasPrice': 1, 'chainId': 1
+            }, False
+        ),
+    ),
+    ids=[
+        'Standard',
+        'Explicit Gas',
+        'Explicit Gas Price',
+        'Explicit Nonce',
+        'With Value',
+    ]
+)
+def test_build_transaction_with_contract_with_arguments(web3, skip_if_testrpc, math_contract,
+                                                        transaction_args,
+                                                        method_args,
+                                                        method_kwargs,
+                                                        expected,
+                                                        skip_testrpc):
+    if skip_testrpc:
+        skip_if_testrpc(web3)
+
+    txn = math_contract.buildTransaction(transaction_args).increment(
+        *method_args, **method_kwargs
+    )
+    expected['to'] = math_contract.address
+    assert txn is not None
+    assert txn == expected