Skip to content

Commit

Permalink
Add contract call test scripts (#48)
Browse files Browse the repository at this point in the history
* add call script

* extract fund script from deploy script

* improve script usage

* update README

* add additional gas to deploy command

* silence extra output on scripts except on error

* add mkacct script

* fix checkstyle/formatting errors

* add tests for decimals and mint

---------

Co-authored-by: Traian-Florin Șerbănuță <[email protected]>
Co-authored-by: Virgil Șerbănuță <[email protected]>
  • Loading branch information
3 people authored Dec 19, 2024
1 parent e48bd42 commit 1bb4927
Show file tree
Hide file tree
Showing 8 changed files with 460 additions and 24 deletions.
24 changes: 19 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ Then, you can start the ULM locally and load the Wasm VM into it by running:
./scripts/ulm-load-lang ./build/lib/libwasm.so
```

As an example, to deploy a Wasm contract, you can do the following:
As an example, to deploy a Wasm contract and invoke a function on it, you can do the following:

1. Install the python scripts:

Expand All @@ -220,15 +220,29 @@ As an example, to deploy a Wasm contract, you can do the following:
make erc20-bin
```

3. Deploy the compiled Wasm contract:
3. Generate an account for use in the test

```sh
poetry -C pykwasm run deploy build/erc20/erc20.bin
poetry -C pykwasm run mkacct | cut -d' ' -f2 > pk_file
```

To invoke a deployed Wasm contract, do the following:
4. Fund the new account

**TODO:** add instructions.
```sh
poetry -C pykwasm fund pk_file http://localhost:8545
```

5. Deploy the compiled Wasm contract from the funded account:

```sh
poetry -C pykwasm run deploy build/erc20/erc20.bin http://localhost:8545 pk_file > contract_addr
```

6. Invoke a contract function from the funded account:

```sh
poetry -C pykwasm run call http://localhost:8545 erc20 $(cat contract_addr) pk_file 0 decimals
```

Resources
---------
Expand Down
6 changes: 6 additions & 0 deletions pykwasm/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ authors = [
]

[tool.poetry.scripts]
mkacct = "pykwasm.mkacct:main"
call = "pykwasm.call:main"
fund = "pykwasm.fund_acct:main"
deploy = "pykwasm.deploy_contract:main"
wasm = "pykwasm.run_wasm:main"
wasm2kore = "pykwasm.wasm2kore:main"
Expand Down Expand Up @@ -68,10 +71,13 @@ skip-string-normalization = true
disallow_untyped_defs = true
# TODO fix type errors
exclude = [
'src/pykwasm/mkacct\.py',
'src/pykwasm/wasm2kast\.py',
'src/pykwasm/wasm2kore\.py',
'src/pykwasm/run_wasm\.py',
'src/pykwasm/fund_acct\.py',
'src/pykwasm/deploy_contract\.py',
'src/pykwasm/call\.py',
'src/wasm/*',
'src/tests/unit/test_wasm2kast\.py',
]
161 changes: 161 additions & 0 deletions pykwasm/src/pykwasm/call.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
#!/usr/bin/python3
import sys
from pathlib import Path

from eth_account import Account
from requests.exceptions import ConnectionError
from web3 import Web3
from web3.exceptions import BadFunctionCallOutput, Web3RPCError
from web3.middleware import SignAndSendRawMiddlewareBuilder

ABI_MAP = {
'erc20': [
{'type': 'function', 'name': 'decimals', 'inputs': [], 'outputs': ['uint8'], 'stateMutability': 'view'},
{'type': 'function', 'name': 'totalSupply', 'inputs': [], 'outputs': ['uint256'], 'stateMutability': 'view'},
{
'type': 'function',
'name': 'balanceOf',
'inputs': [{'name': 'owner', 'type': 'address'}],
'outputs': [{'name': '', 'type': 'uint256'}],
'stateMutability': 'view',
},
{
'type': 'function',
'name': 'transfer',
'inputs': [{'name': 'to', 'type': 'address'}, {'name': 'value', 'type': 'uint256'}],
'outputs': [{'name': '', 'type': 'bool'}],
},
{
'type': 'function',
'name': 'transferFrom',
'inputs': [
{'name': 'from', 'type': 'address'},
{'name': 'to', 'type': 'address'},
{'name': 'value', 'type': 'uint256'},
],
'outputs': [{'name': '', 'type': 'bool'}],
},
{
'type': 'function',
'name': 'approve',
'inputs': [{'name': 'spender', 'type': 'address'}, {'name': 'value', 'type': 'uint256'}],
'outputs': [{'name': '', 'type': 'bool'}],
},
{
'type': 'function',
'name': 'allowance',
'inputs': [{'name': 'owner', 'type': 'address'}, {'name': 'spender', 'type': 'address'}],
'outputs': [{'name': '', 'type': 'uint256'}],
'stateMutability': 'view',
},
{
'type': 'function',
'name': 'mint',
'inputs': [{'name': 'account', 'type': 'address'}, {'name': 'value', 'type': 'uint256'}],
'outputs': [],
},
]
}


def parse_arg(param_ty, arg):
match param_ty:
case 'uint256':
try:
return int(arg)
except ValueError:
pass
try:
return int(arg, 16)
except ValueError as err:
raise ValueError(f'Failed to parse numeric argument {arg}') from err
case 'address':
assert Web3.is_address(arg)
return arg


def parse_params(abi, method, args):
for elt in abi:
parsed_args = []
ty, name, inputs = elt['type'], elt['name'], elt['inputs']
if ty == 'function' and name == method:
if len(inputs) != len(args):
raise ValueError('call to method {method} with {inputs} has incorrect parameters {params}')
for param, arg in zip(inputs, args, strict=True):
parsed_args.append(parse_arg(param['type'], arg))
break
else:
raise ValueError(f'method {method} not found in contract ABI')
return parsed_args


def run_method(w3, contract, sender, eth, method, params):
func = contract.functions[method](*params)
view_like = func.abi.get('stateMutability', 'nonpayable') in {'view', 'pure'}
try:
if view_like:
result_or_receipt = func.call()
else:
tx_hash = func.transact({'from': sender.address, 'value': eth})
result_or_receipt = w3.eth.wait_for_transaction_receipt(tx_hash)
except (ConnectionError, BadFunctionCallOutput, Web3RPCError) as e:
if isinstance(e, (ConnectionError, ConnectionRefusedError)):
msg = f'Failed to connect to node: {e.message}'
elif isinstance(e, BadFunctionCallOutput):
msg = f'Could not interpret function output: {",".join(e.args)}'
else:
msg = f'Node RPC encountered an error: {e.message}'
print(msg, file=sys.stderr)
sys.exit(1)

return (view_like, result_or_receipt)


USAGE = 'call.py <node_url> <contract_abi> <contract_address_lit_or_file> <sender_private_key_file> <eth> <method> [param...]'


def main():
args = sys.argv[1:]
if len(args) < 6:
print(USAGE, file=sys.stderr)
sys.exit(1)
(node_url, abi_name, addr_lit_or_file, sender_pk_file, eth, method), params = args[:6], args[6:]
# get web3 instance
w3 = Web3(Web3.HTTPProvider(node_url))
# get abi
abi = ABI_MAP[abi_name]
# get contract
try:
contract_addr = Path(addr_lit_or_file).read_text().strip()
except FileNotFoundError:
contract_addr = addr_lit_or_file
contract = w3.eth.contract(address=contract_addr, abi=abi)
# validate method
try:
contract.functions[method]
except BaseException: # noqa: B036
print(f'Invalid method {method} for {abi_name} contract ABI', file=sys.stderr)
sys.exit(1)
# get sender
pk = bytes.fromhex(Path(sender_pk_file).read_text().strip().removeprefix('0x'))
sender = Account.from_key(pk)
# add signer
w3.middleware_onion.inject(SignAndSendRawMiddlewareBuilder.build(sender), layer=0)
# parse params
params = parse_params(abi, method, params)
eth = int(eth)
# run method
(view_like, result_or_receipt) = run_method(w3, contract, sender, eth, method, params)
# handle result
if view_like:
print(result_or_receipt)
else:
# return exit code based on status which is 1 for confirmed and 0 for reverted
success = bool(result_or_receipt['status'])
if not success:
print(result_or_receipt, file=sys.stderr)
sys.exit(int(not success))


if __name__ == '__main__':
main()
57 changes: 39 additions & 18 deletions pykwasm/src/pykwasm/deploy_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,42 +4,48 @@

from eth_account import Account
from web3 import Web3
from web3.exceptions import Web3RPCError
from web3.middleware import SignAndSendRawMiddlewareBuilder


def deploy_contract(node_url, sender, contract_hex):
w3 = Web3(Web3.HTTPProvider(node_url))
if sender is None:
sender = w3.eth.account.create()
# fund sender acct
fund_tx_hash = w3.eth.send_transaction(
{'from': w3.eth.accounts[0], 'to': sender.address, 'value': 1000000000000000000}
)
fund_tx_receipt = w3.eth.wait_for_transaction_receipt(fund_tx_hash)
w3.middleware_onion.inject(SignAndSendRawMiddlewareBuilder.build(sender), layer=0)
def deploy_contract(w3, sender, contract_hex):
# deploy txn
deploy_token_tx = {
'from': sender.address,
'data': contract_hex,
'to': '',
'value': 0,
'gas': 11000000,
# NOTE: we provide extra gas to the txn here
# because, by default, the estimator does
# not give us enough gas for our very
# large contract files
'gas': 110000000,
'maxFeePerGas': 2000000000,
'maxPriorityFeePerGas': 1000000000,
}
deploy_tx_hash = w3.eth.send_transaction(deploy_token_tx)
deploy_tx_receipt = w3.eth.wait_for_transaction_receipt(deploy_tx_hash)
return fund_tx_receipt, deploy_tx_receipt
try:
deploy_tx_hash = w3.eth.send_transaction(deploy_token_tx)
deploy_tx_receipt = w3.eth.wait_for_transaction_receipt(deploy_tx_hash)
except ConnectionError:
print('Failed to connect to node', file=sys.stderr)
sys.exit(1)
except Web3RPCError as e:
print(f'Failed to deploy contract to node: {e.message}', file=sys.stderr)
sys.exit(1)
return deploy_tx_receipt


USAGE = 'deploy_contract.py <contract_file> [node_url] [sender_private_key_file]'


def main():
# check arg length
args = sys.argv[1:]
if len(args) < 1:
print(USAGE)
print(USAGE, file=sys.stderr)
sys.exit(1)

# parse args
contract_hex = Path(args[0]).read_text().strip()
node_url = 'http://localhost:8545'
sender = None
Expand All @@ -48,9 +54,24 @@ def main():
if len(args) > 2:
pk = bytes.fromhex(Path(args[2]).read_text().strip().removeprefix('0x'))
sender = Account.from_key(pk)
fund_receipt, deploy_receipt = deploy_contract(node_url, sender, contract_hex)
print(fund_receipt)
print(deploy_receipt)

# get w3 instance
w3 = Web3(Web3.HTTPProvider(node_url))
w3.middleware_onion.inject(SignAndSendRawMiddlewareBuilder.build(sender), layer=0)

# deploy contract
deploy_receipt = deploy_contract(w3, sender, contract_hex)

# get address and status
contract_address = deploy_receipt['contractAddress']
success = bool(deploy_receipt['status'])

# print contract address on success, nothing on failure
if success and contract_address:
print(contract_address)
else:
print(deploy_receipt, file=sys.stderr)
sys.exit(1)


if __name__ == '__main__':
Expand Down
59 changes: 59 additions & 0 deletions pykwasm/src/pykwasm/fund_acct.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
#!/usr/bin/python3
import sys
from pathlib import Path

from eth_account import Account
from requests.exceptions import ConnectionError
from web3 import Web3

# from web3.middleware import SignAndSendRawMiddlewareBuilder


def fund_acct(w3, addr):
try:
fund_tx_hash = w3.eth.send_transaction({'from': w3.eth.accounts[0], 'to': addr, 'value': 1000000000000000000})
fund_tx_receipt = w3.eth.wait_for_transaction_receipt(fund_tx_hash)
except ConnectionError:
print('Failed to connect to node', file=sys.stderr)
sys.exit(1)
return fund_tx_receipt


USAGE = 'fund_acct.py <address_or_pk_file> [node_url]'


def main():
# check arg count
args = sys.argv[1:]
if len(args) < 1 or len(args) > 2:
print(USAGE, file=sys.stderr)
sys.exit(1)

# parse args
addr_or_pkfile = args[0]
if not Web3.is_address(addr_or_pkfile):
pk = bytes.fromhex(Path(addr_or_pkfile).read_text().strip().removeprefix('0x'))
addr = Account.from_key(pk).address
else:
addr = addr_or_pkfile
node_url = 'http://localhost:8545'
if len(args) > 1:
node_url = args[1]

# fund acct
w3 = Web3(Web3.HTTPProvider(node_url))
fund_receipt = fund_acct(w3, addr)

# return exit code based on status which is 1 for confirmed and 0 for reverted
success = bool(fund_receipt['status'])

# print receipt on failure
if not success:
print(fund_receipt)

# set exit code
sys.exit(int(not success))


if __name__ == '__main__':
main()
16 changes: 16 additions & 0 deletions pykwasm/src/pykwasm/mkacct.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from web3 import Web3


def mkaddr():
w3 = Web3()
acct = w3.eth.account.create()
return (acct.address, w3.to_hex(acct.key))


def main():
address, key = mkaddr()
print(f'{address} {key}')


if __name__ == '__main__':
main()
Loading

0 comments on commit 1bb4927

Please sign in to comment.