diff --git a/.gitignore b/.gitignore index d9a84d8a68bb72dcd95e39d300b55eddef2c7e98..3e24356fae49d2010ee9c2b4075f40faab78503a 100644 --- a/.gitignore +++ b/.gitignore @@ -10,5 +10,7 @@ test2network*/node test2network*/bootnode test2network/*.pid node_modules +bin/venv src/*/*.bin src/*/*.abi +__pycache__ diff --git a/bin/libbfa/__README__.md b/bin/libbfa/__README__.md new file mode 100755 index 0000000000000000000000000000000000000000..e5ccf3b28317b36d6e641d9deb80f2f7a45d3b9a --- /dev/null +++ b/bin/libbfa/__README__.md @@ -0,0 +1,128 @@ +# 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()) +``` diff --git a/bin/libbfa/__init__.py b/bin/libbfa/__init__.py new file mode 100755 index 0000000000000000000000000000000000000000..e22c7f41188f07df10965869913ebb2fcf5b1426 --- /dev/null +++ b/bin/libbfa/__init__.py @@ -0,0 +1,328 @@ +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()) diff --git a/bin/libbfa/requirements.txt b/bin/libbfa/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..08b1a03bf0a6d5cefc84a40351ed873cffa9e184 --- /dev/null +++ b/bin/libbfa/requirements.txt @@ -0,0 +1,4 @@ +web3 +rusty-rlp +ecdsa +Crypto diff --git a/bin/ssn-test.py b/bin/ssn-test.py new file mode 100755 index 0000000000000000000000000000000000000000..207268069cbe34363fc58adb569b1467a1090a8d --- /dev/null +++ b/bin/ssn-test.py @@ -0,0 +1,21 @@ +#!/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)