Skip to content
Snippets Groups Projects
Commit b321025d authored by Robert Martin-Legene's avatar Robert Martin-Legene
Browse files

Working on libbfa in python

parent 229f6667
No related branches found
No related tags found
No related merge requests found
......@@ -10,5 +10,7 @@ test2network*/node
test2network*/bootnode
test2network/*.pid
node_modules
bin/venv
src/*/*.bin
src/*/*.abi
__pycache__
# libbfa.py
## Library for Python3 to talk to your local POA node.
```python
#!/usr/bin/env python3
import libbfa
bee = '0xbee0966BdC4568726AB3d3131b02e6255e29285D'
d18 = '0xbfA2c97c3f59cc929e8fEB1aEE2Aca0B38235d18'
```
Which network do you want to connect to?
If you want to connect to an open/public node
you can specify `bfa2018` or `test2`. If you
want to connect to another (your own?) node,
you can specify the URL, or give a `Provider`
```python
bfa = libbfa.Bfa('test2')
bfa = libbfa.Bfa('http://localhost:8545')
```
Find the file locally which matches the name
mentioned in the first argument. '0x' is
removed before case-insensitive matching
is performed.
Second argument (if specified) is the password.
```python
acct = libbfa.Account(bee)
acct = libbfa.Account(bee, 'pepe')
```
Create a skeleton for the 'factory'
The name of the contract is given in the second
argument and must be a contract in the current
directory (not symlinked).
If the contract is not compiled locally already
your local docker installation is used (must have).
```python
Factory = libbfa.CompiledContract(bfa.w3, 'Majority')
```
### Deploy a new contract.
If no address is given, an
account (object) must be given, which will be used
to deploy a new instance of the contract on the
blockchain. *You usually only want to deploy a
contract one time.* (If you need multiple tries,
consider using the test-net).
If you choose to deploy a new contract, remember to
give the arguments that the contract wishes for it's
constructor **or you will get an absurd error message
about being out of gas**.
The number `86400` in this example is the desired
argument for deployment of this particular contract
that the smart contract will get passed to it's
constructor. This contract takes a `uint256`.
Once you succesfully have a deployed contract in the
network, you can find it's address in the receipt's
`.address` field. Make a note of that address,
because you must use that address for all
subsequent calls to reach that contract.
```python
newdeployment = Factory.instance(86400, account=acct)
contractaddress = newdeployment.address
print('Your contract is deployed at address ' + contractaddress)
newdeployment = None
```
Now it is deployed and we can pretend that happened
many days ago. The 4 lines above need not be repeated,
but the others do (libbfa, account and Factory setup).
Now, we'll create a new reference to the same contract.
Since our contract is deployed now, we
see how to reference the already deployed
contract.
```python
samecontract = Factory.instance(address=contractaddress)
# better variable name for later
majority=samecontract
```
call()s are free (no gas needed) and local to the node
you are working with.
They require no account, no signature, no
transaction and are almost instant.
```python
print('Council length: {}'.format(majority.functions.councilLength().call()))
print('Votes length: {}'.format(majority.functions.votesLength().call()))
print('isCouncil bee: {}'.format(majority.functions.isCouncil(bee).call()))
print('mayVote bee,d18,True: {}'.format(majority.functions.mayVote(bee,d18,True).call()))
print('mayVote bee,d18,False: {}'.format(majority.functions.mayVote(bee,d18,False).call()))
print('mayVote d18,bee,True: {}'.format(majority.functions.mayVote(d18,bee,True).call()))
```
Transactions form part of the blockchain and must be mined
by the sealers, so they take a little longer to complete.
They are signed by the sender account.
Your *kwargs* must reference a configured `web3` (probably the one from
`libbfa`), and also give a reference to the `function` that you are
going to send a transaction to.
The first arguments (all the *args*) will be sent to the function in
the smart contract, so make sure the match in type.
The function name is a funny mix between Python variable names you
have defined yourself, plus functions which have arisen from the smart
contracts ABI.
```python
r = acct.transact(d18, False, web3=bfa.w3, function=majority.functions.vote)
print(r)
```
### Error examples
<a name="argument-after-asterisk-must-be-an-iterable-not-nonetype"></a>
argument after * must be an iterable, not NoneType
```python
# Error text: argument after * must be an iterable, not NoneType
print(majorcontr.functions.councilLength.call())
# Fix
print(majorcontr.functions.councilLength().call())
```
import os
import sys
import subprocess
import json
import re
import web3
import web3.exceptions
import web3.middleware
import ecdsa
from Crypto.Hash import keccak
class Bfa:
def __init__(self, provider=''):
if 'BFAHOME' not in os.environ:
if os.path.isdir('/home/bfa/bfa'):
os.putenv('BFAHOME', '/home/bfa/bfa')
elif 'HOME' in os.environ and os.path.isdir(os.path.join(os.environ['HOME'], 'bfa')):
os.putenv('BFAHOME', os.path.join(os.environ['HOME'], 'bfa'))
if isinstance(provider, str):
if provider in ['prod', 'bfa2018' 'network']:
provider = web3.HTTPProvider("http://public.bfa.ar:8545/")
elif provider in ['test2network', 'test2', 'test', '']:
provider = web3.HTTPProvider("http://public.test2.bfa.ar:8545/")
elif provider.startswith('http://') or provider.startswith('https://'):
provider = web3.HTTPProvider(provider)
else:
raise ValueError('I do not know how to handle that provider.')
w3 = web3.Web3(provider)
# inject POA compatibility middleware
w3.middleware_onion.inject(web3.middleware.geth_poa_middleware, layer=0)
self.w3 = w3
class Account:
def __init__(self, accountname: str, passphrase: str = ''):
self.keyfile = None
self.privatekey = None
self.unlock(accountname, passphrase)
self.nonce = 0
def __repr__(self) -> str:
return str(dict(keyfile=self.keyfile, privatekey=str(self.privatekey)))
def __str__(self) -> str:
return self.walletaddress()
@staticmethod
def findkeyfilesindirectories(pattern: str):
# Remove leading 0x if present
if pattern.startswith('0x'):
pattern = pattern[2:]
# Lower case the pattern (account name)
pattern = pattern.lower()
# Find candidate directories of where to look for accounts
wheretolook = []
if os.getenv('BFANODEDIR'):
wheretolook += [os.path.join(os.environ['BFANODEDIR'], 'keystore')]
elif os.getenv('BFANETWORKDIR'):
wheretolook += [os.path.join(os.environ['BFANETWORKDIR'], 'node', 'keystore')]
elif os.getenv('BFAHOME'):
wheretolook += [os.path.join(os.environ['BFANETWORKDIR'], 'network', 'node', 'keystore')]
if os.getenv('HOME'):
wheretolook += [
os.path.join(os.environ['HOME'], '.ethereum', 'keystore'),
os.path.join(os.environ['HOME'], '.ethereum', 'keystore', 'test2'),
os.path.join(os.environ['HOME'], '.ethereum', 'keystore', 'network')
]
# Look for our pattern (or all files) in the directories
matches = []
ourregexp = '.*{}$'.format(pattern)
for d in wheretolook:
if os.path.exists(d):
for f in os.listdir(d):
fn = os.path.join(d, f)
if os.path.isfile(fn):
if re.match(ourregexp, f.lower()):
matches += [os.path.join(d, f)]
if pattern:
if len(matches) == 0:
# if a pattern was given but no matches were found,
# return None
return None
else:
# if a pattern was found but some matches were found,
# return just the first one
return matches[0]
# If no pattern was given, return everything found, or the
# empty list.
return matches
def unlock(self, accountname: str, passphrase: str):
if os.path.isfile(accountname):
self.keyfile = accountname
else:
self.keyfile = self.findkeyfilesindirectories(accountname)
if self.keyfile is None:
raise FileNotFoundError('The account was not found.')
with open(self.keyfile) as fd:
encrypted_key = fd.read()
try:
self.privatekey = web3.Web3().eth.account.decrypt(encrypted_key, passphrase)
except ValueError as exc:
raise ValueError(
'The passphrase given for the account is incorrect, '
'or the input file is not a valid key file.'
) from exc
def publickey(self) -> bytearray:
key = ecdsa.SigningKey.from_string(self.privatekey, curve=ecdsa.SECP256k1).verifying_key
# returns bytestring
return key.to_string()
def walletaddress(self) -> str:
ourhash = keccak.new(digest_bits=256)
ourhash.update(self.publickey())
digest = ourhash.hexdigest()
return web3.Web3().toChecksumAddress('0x' + digest[-40:])
def transact(self, *args, **kwargs):
w3 = kwargs.get('web3')
afunction = kwargs.get('function')
tx_details = self.calculate_tx_details(w3, afunction, *args)
txobj = afunction(*args).buildTransaction(tx_details)
receipt = self.txsignsendwait(w3, txobj)
return receipt
def txsignsendwait(self, w3: web3.Web3, txobj: dict):
signedobj = self.signtx(txobj)
txhashbytes = w3.eth.sendRawTransaction(signedobj.rawTransaction)
self.nonce = self.nonce + 1
receipt = w3.eth.waitForTransactionReceipt(txhashbytes)
return receipt
def signtx(self, tx: dict):
signed = web3.Web3().eth.account.sign_transaction(tx, self.privatekey)
return signed
def calculate_tx_details(self, w3: web3.Web3, afunction, *args) -> dict:
# Nonce may have increase on the network without us noticing
# or past transactions may not yet have been mined (and a flooded
# txpool).
# This is a resonable fix (try not to send too many transactions)
# If you use waitForTransactionReceipt() between each transaction
# you will not have problems because of this.
self.nonce = max(self.nonce, w3.eth.getTransactionCount(self.walletaddress()))
# Set minimum gasPrice to 1 Gwei, but allow more if the network says so.
details = {
'chainId': w3.eth.chain_id(),
'gasPrice': min(w3.toWei('1', 'gwei'), w3.eth.gasPrice),
'nonce': self.nonce,
'from': self.walletaddress(),
}
# Ask for balance, so we can tell it, in case we have an exception
balance = w3.eth.getBalance(self.walletaddress())
try:
# Ask a node how much gas it would cost to deploy
gas = afunction(*args).estimateGas(details)
except web3.exceptions.SolidityError as exc:
raise web3.exceptions.SolidityError(
'The Ethereum Virtual Machine probably did not like that.'
) from exc
except ValueError as exc:
raise ValueError(
'Your account may not have enough balance to work on this '
'network. It currently has {} wei.'
.format(balance)) from exc
details['gas'] = gas
return details
class Abi(list):
def __str__(self):
txt = ''
for i in range(len(self)):
elem = self.__getitem__(i)
if type(elem) is not dict:
continue
if 'type' in elem:
txt += "({}) ".format(elem['type'])
name = ""
if 'name' in elem:
name = elem['name']
if 'inputs' in elem:
inputlist = list()
for inputnum in range(len(elem['inputs'])):
_input = elem['inputs'][inputnum]
args = _input['type']
if 'name' in _input and _input['name'] != '':
args = "{}: {}".format(_input['name'], args)
inputlist.append(args)
txt += "{}({})".format(name, ', '.join(inputlist))
if 'outputs' in elem:
outputlist = list()
for outputnum in range(len(elem['outputs'])):
output = elem['outputs'][outputnum]
if 'name' in output and output['name'] != '':
outputlist.append("{}: {}".format(output['name'], output['type']))
else:
outputlist.append("{}".format(output['type']))
txt += " -> ({})".format(', '.join(outputlist))
if 'stateMutability' in elem:
txt += ' [{}]'.format(elem['stateMutability'])
txt += "\n"
return txt
# print(txt, file=sys.stderr)
# return super(Abi, self).__str__()
class CompiledContract:
solc_features = [
'abi', 'asm', 'ast', 'bin', 'bin-runtime', 'compact-format',
'devdoc', 'generated-sources', 'generated-sources-runtime',
'hashes', 'interface', 'metadata', 'opcodes', 'srcmap',
'srcmap-runtime', 'storage-layout', 'userdoc'
]
dockerWorkdir = '/casa'
def __init__(self, w3: web3.Web3, name: str):
self.w3 = w3
self.name = name
self.json = None
self.readtextfile()
# Did read give us json, or should we compile it?
if self.json is None:
self.compile()
self.writetextfile()
def dockerargs(self):
return [
'docker', 'run',
'--rm',
# Mount our cwd as /casa
'-v',
'{}:{}'.format(os.getcwd(), self.dockerWorkdir),
# Run as us inside the docker, so we have access to this directory
'-u', str(os.getuid()),
'bfaar/nodo',
'/usr/local/bin/solc',
'--evm-version', 'byzantium',
# Get as many things dumped into our JSON as possible.
# You never know when you'll need it, and space is cheap.
'--combined-json', ','.join(self.solc_features),
# We like optimized things.
'--optimize',
# File name of input file.
'{}/contract.sol'.format(self.dockerWorkdir)
]
def compile(self):
# Make a copy with a fixed name
# Mostly so people can use symlinks to the source files
# which won't work when we call docker.
with open('{}.sol'.format(self.name), 'r') as infile:
with open('contract.sol', 'w') as outfile:
outfile.write(infile.read())
solc = subprocess.run(self.dockerargs(), stdout=subprocess.PIPE, check=True)
# Don't leave too much mess.
os.remove('contract.sol')
txt = solc.stdout
output = txt.decode('utf-8')
self.json = json.loads(output)
def readtextfile(self):
filename = self.name + '.compiled.json'
try:
with open(filename, 'rt', encoding='utf-8') as infile:
output = infile.read()
except FileNotFoundError:
return
if len(output) < 2:
print("The JSON file is too little ({} bytes read from {}).".format(len(output), filename), file=sys.stderr)
raise NameError
try:
self.json = json.loads(output)
except json.decoder.JSONDecodeError as exc:
print("It was not possible to parse the JSON file (from is {}).".format(filename), file=sys.stderr)
raise exc
def writetextfile(self):
filename = self.name + '.compiled.json'
try:
with open(filename, 'xt', encoding='utf-8') as outfile:
outfile.write(json.dumps(self.json))
except:
os.remove(filename)
raise
def _where(self):
# Inch our way closer and closer, all the time,
# as long as we see labels which look vaguely familiar.
# This may enable us to be more liberal in the JSON input
# we receive from the file containing the cached compiled JSON.
point = self.json
for teststring in [
'contracts',
'{}/{}.sol:{}'.format(self.dockerWorkdir, 'contract', self.name),
'{}/{}.sol:{}'.format(self.dockerWorkdir, self.name, self.name)
]:
if type(point) is dict and teststring in point:
point = point[teststring]
return point
def bytecode(self):
self._where()
return '0x' + self._where()['bin']
def abi(self):
# Old method which also works, but our own class has a nicer __str__:
# return json.loads(self.json['contracts'][where]['abi'])
return Abi(json.loads(self._where()['abi']))
def instance(self, *args, **kwargs):
addr = kwargs.get('address')
if addr is None:
account = kwargs.get('account')
if account is None:
raise KeyError('Either address or account must be speficied, in order to get an instance.')
ourinstance = self.w3.eth.contract(abi=self.abi(), bytecode=self.bytecode())
receipt = account.transact(*args, web3=self.w3, function=ourinstance.constructor)
if receipt.status == 0:
raise SystemError('Failed to deploy.')
addr = receipt.contractAddress
return self.w3.eth.contract(address=addr, abi=self.abi())
web3
rusty-rlp
ecdsa
Crypto
#!/usr/bin/env python3
import libbfa
bee = '0xbee0966BdC4568726AB3d3131b02e6255e29285D'
majoraddr = None
majoraddr = '0xE676dCaabF935B78beC37730cd5fE243Af1D7329'
bfa = libbfa.Bfa('test2')
acct = libbfa.Account(bee, '')
Majority = libbfa.CompiledContract(bfa.w3, 'Majority')
majorcontr = Majority.instance(86400 * 7, address=majoraddr, account=acct)
majoraddr = majorcontr.address
print("La direccion de Majority es", majoraddr)
print(majorcontr.functions.councilLength().call())
Cauciones = libbfa.CompiledContract(bfa.w3, 'cauciones')
cauaddr = None
cauaddr = "0xcc3D37deD642BB3E7b20d66530667AE7E6CB3750"
caucontr = Cauciones.instance(majoraddr, account=acct, address=cauaddr)
cauaddr = caucontr.address
print("La direccion de cauciones es", cauaddr)
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment