diff --git a/.gitignore b/.gitignore index 70aa61b..56c4d6a 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ lextab.py yacctab.py *.swp *.swo +*.pid diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..1769bec --- /dev/null +++ b/.travis.yml @@ -0,0 +1,10 @@ +sudo: enabled +language: python +python: + - '3.6' +install: + - make requirements-dev requirements +script: + - make travis +git: + depth: 3 diff --git a/Dockerfile.alpine-python b/Dockerfile.alpine-python index 92b6d79..b4a12f5 100644 --- a/Dockerfile.alpine-python +++ b/Dockerfile.alpine-python @@ -1,4 +1,4 @@ -FROM jfloff/alpine-python:2.7 +FROM jfloff/alpine-python:3.6 COPY . /app/ WORKDIR /app diff --git a/Makefile b/Makefile index b338144..51b18e2 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ ROOT_DIR := $(shell dirname $(realpath $(MAKEFILE_LIST))) SOLC=$(ROOT_DIR)/node_modules/.bin/solcjs -PYTHON=python +PYTHON=python3 NPM=npm GANACHE=$(ROOT_DIR)/node_modules/.bin/ganache-cli TRUFFLE=$(ROOT_DIR)/node_modules/.bin/truffle @@ -28,7 +28,9 @@ check-prereqs: clean: rm -rf build chaindata dist find . -name '*.pyc' -exec rm '{}' ';' - rm -rf *.pyc *.pdf *.egg-info + find . -name '__pycache__' -exec rm -rf '{}' ';' + rm -rf *.pyc *.pdf *.egg-info *.pid *.log + rm -f lextab.py yacctab.py ####################################################################### @@ -76,11 +78,16 @@ solidity-lint: nodejs-requirements: $(NPM) install +# Useful shortcut for development, install packages to user path by default +python-pip-user: + mkdir -p $(HOME)/.pip/ + echo -e "[global]\nuser = true\n" > $(HOME)/.pip/pip.conf + python-requirements: requirements.txt - $(PYTHON) -mpip install --user -r $< + $(PYTHON) -mpip install -r $< python-dev-requirements: requirements-dev.txt - $(PYTHON) -mpip install --user -r $< + $(PYTHON) -mpip install -r $< requirements-dev: nodejs-requirements python-dev-requirements @@ -128,15 +135,36 @@ build/%.combined.sol: contracts/%.sol build # Testing and unit test harnesses # +# runs an instance of testrpc in background, then waits for it to be ready +travis-testrpc-start: travis-testrpc-stop + $(NPM) run testrpca > .testrpc.log & echo $$! > .testrpc.pid + while true; do echo -n . ; curl http://localhost:8545 &> /dev/null && break || sleep 1; done + +# Stops previ +travis-testrpc-stop: + if [ -f .testrpc.pid ]; then kill `cat .testrpc.pid` || true; rm -f .testrpc.pid; fi + +travis: travis-testrpc-start truffle-deploy-a contracts test + + testrpc: $(NPM) run testrpca +testrpc-b: + $(NPM) run testrpcb + test-js: $(NPM) run test test-unit: $(PYTHON) -m unittest discover test/ +test-coordserver: + $(PYTHON) -mion htlc coordinator --contract 0xd833215cbcc3f914bd1c9ece3ee7bf8b14f841bb + +test-coordclient: + PYTHONPATH=. $(PYTHON) ./test/test_coordclient.py + test: test-unit test-js @@ -148,5 +176,11 @@ test: test-unit test-js truffle-deploy: $(TRUFFLE) deploy +truffle-deploy-a: + $(TRUFFLE) deploy --network testrpca --reset + +truffle-deploy-b: + $(TRUFFLE) deployb --network testrpcb --reset + truffle-console: $(TRUFFLE) console \ No newline at end of file diff --git a/abi/HTLC.abi b/abi/HTLC.abi index cac840d..f18b97b 100644 --- a/abi/HTLC.abi +++ b/abi/HTLC.abi @@ -1 +1 @@ -[{"constant":false,"inputs":[{"name":"inReceiver","type":"address"},{"name":"inSecretHashed","type":"bytes32"},{"name":"inExpiry","type":"uint256"}],"name":"Deposit","outputs":[{"name":"","type":"bytes32"}],"payable":true,"stateMutability":"payable","type":"function"},{"constant":false,"inputs":[{"name":"inExchGUID","type":"bytes32"}],"name":"Refund","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"name":"inExchGUID","type":"bytes32"},{"name":"inSecret","type":"bytes32"}],"name":"Withdraw","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[{"name":"inExchGUID","type":"bytes32"}],"name":"GetState","outputs":[{"name":"","type":"uint8"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"name":"","type":"bytes32"}],"name":"exchanges","outputs":[{"name":"secretHashed","type":"bytes32"},{"name":"sender","type":"address"},{"name":"receiver","type":"address"},{"name":"amount","type":"uint256"},{"name":"expiry","type":"uint256"},{"name":"state","type":"uint8"}],"payable":false,"stateMutability":"view","type":"function"},{"anonymous":false,"inputs":[{"indexed":false,"name":"exchGUID","type":"bytes32"},{"indexed":true,"name":"receiver","type":"address"},{"indexed":false,"name":"secretHashed","type":"bytes32"},{"indexed":false,"name":"expiry","type":"uint256"}],"name":"OnDeposit","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"name":"exchGUID","type":"bytes32"}],"name":"OnRefund","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"name":"exchGUID","type":"bytes32"},{"indexed":false,"name":"secret","type":"bytes32"}],"name":"OnWithdraw","type":"event"}] \ No newline at end of file +[{"constant":true,"inputs":[{"name":"inExchGUID","type":"bytes32"}],"name":"GetReceiver","outputs":[{"name":"","type":"address"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"name":"inReceiver","type":"address"},{"name":"inSecretHashed","type":"bytes32"},{"name":"inExpiry","type":"uint256"}],"name":"Deposit","outputs":[{"name":"","type":"bytes32"}],"payable":true,"stateMutability":"payable","type":"function"},{"constant":true,"inputs":[{"name":"inExchGUID","type":"bytes32"}],"name":"GetAmount","outputs":[{"name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"name":"inExchGUID","type":"bytes32"}],"name":"Refund","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"name":"inExchGUID","type":"bytes32"},{"name":"inSecret","type":"bytes32"}],"name":"Withdraw","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[{"name":"inExchGUID","type":"bytes32"}],"name":"GetExpiry","outputs":[{"name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"name":"inExchGUID","type":"bytes32"}],"name":"GetSender","outputs":[{"name":"","type":"address"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"name":"inExchGUID","type":"bytes32"}],"name":"GetState","outputs":[{"name":"","type":"uint8"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"name":"","type":"bytes32"}],"name":"exchanges","outputs":[{"name":"secretHashed","type":"bytes32"},{"name":"sender","type":"address"},{"name":"receiver","type":"address"},{"name":"amount","type":"uint256"},{"name":"expiry","type":"uint256"},{"name":"state","type":"uint8"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"name":"inExchGUID","type":"bytes32"}],"name":"GetSecretHashed","outputs":[{"name":"","type":"bytes32"}],"payable":false,"stateMutability":"view","type":"function"},{"anonymous":false,"inputs":[{"indexed":false,"name":"exchGUID","type":"bytes32"},{"indexed":true,"name":"receiver","type":"address"},{"indexed":false,"name":"secretHashed","type":"bytes32"},{"indexed":false,"name":"expiry","type":"uint256"}],"name":"OnDeposit","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"name":"exchGUID","type":"bytes32"}],"name":"OnRefund","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"name":"exchGUID","type":"bytes32"},{"indexed":false,"name":"secret","type":"bytes32"}],"name":"OnWithdraw","type":"event"}] \ No newline at end of file diff --git a/contracts/HTLC.sol b/contracts/HTLC.sol index 6c852d9..f0aabce 100644 --- a/contracts/HTLC.sol +++ b/contracts/HTLC.sol @@ -31,6 +31,44 @@ contract HTLC { mapping (bytes32 => Exchange) public exchanges; + function GetExchange ( bytes32 inExchGUID ) + internal view returns (Exchange storage) + { + Exchange storage exch = exchanges[inExchGUID]; + require( exch.state != ExchangeState.Invalid ); + return exch; + } + + function GetSender ( bytes32 inExchGUID ) + public view returns (address) + { + return GetExchange(inExchGUID).sender; + } + + function GetReceiver ( bytes32 inExchGUID ) + public view returns (address) + { + return GetExchange(inExchGUID).receiver; + } + + function GetSecretHashed ( bytes32 inExchGUID ) + public view returns (bytes32) + { + return GetExchange(inExchGUID).secretHashed; + } + + function GetExpiry ( bytes32 inExchGUID ) + public view returns (uint256) + { + return GetExchange(inExchGUID).expiry; + } + + function GetAmount ( bytes32 inExchGUID ) + public view returns (uint256) + { + return GetExchange(inExchGUID).amount; + } + function GetState ( bytes32 inExchGUID ) public view returns (ExchangeState) { diff --git a/example.sh b/example.sh index 9dc76fe..a450001 100755 --- a/example.sh +++ b/example.sh @@ -1,5 +1,5 @@ #!/bin/bash - +PYTHON=python3 ACC_A=0x22d491bde2303f2f43325b2108d26f1eaba1e32b ACC_B=0xffcf8fdee72ac11b5c542428b35eef5769c409f0 TOKEN_ADDR=0x254dffcd3277c0b1660f6d42efbb754edababc2b @@ -11,50 +11,56 @@ IP_A=127.0.0.1 IP_B=127.0.0.1 API_PORT_A=8555 API_PORT_B=8556 +REF=`openssl rand 32 | sha256sum | cut -f 1 -d ' '` +VALUE=$(( ( RANDOM % 10000 ) + 1000 )) + +echo $PYTHON -mion lithium --from-account $ACC_A --to-account $ACC_B --rpc-from $IP_A:$PORT_A --rpc-to $IP_B:$PORT_B --lock $LOCK_ADDR --link $LINK_ADDR --api-port $API_PORT_A +echo $PYTHON -mion lithium --from-account $ACC_A --to-account $ACC_B --rpc-from $IP_B:$PORT_B --rpc-to $IP_A:$PORT_A --lock $LOCK_ADDR --link $LINK_ADDR --api-port $API_PORT_B +read enter echo "==== Chain A ====" echo "...Minting" -python -mion ion mint --rpc $IP_A:$PORT_A --account $ACC_A --tkn $TOKEN_ADDR --value 5000 +$PYTHON -mion ion mint --rpc $IP_A:$PORT_A --account $ACC_A --tkn $TOKEN_ADDR --value $VALUE echo "" echo "Press any key to proceed" read enter echo "...Depositing" -python -mion ion deposit --rpc $IP_A:$PORT_A --account $ACC_A --lock $LOCK_ADDR --tkn $TOKEN_ADDR --value 5000 --ref stuff +$PYTHON -mion ion deposit --rpc $IP_A:$PORT_A --account $ACC_A --lock $LOCK_ADDR --tkn $TOKEN_ADDR --value $VALUE --ref $REF echo "" echo "Press any key to proceed" read enter echo "...Fetching proof" -python -mion ion proof --lithium-port $API_PORT_A --account $ACC_A --lock $LOCK_ADDR --tkn $TOKEN_ADDR --value 5000 --ref stuff +$PYTHON -mion ion proof --lithium-port $API_PORT_A --account $ACC_A --lock $LOCK_ADDR --tkn $TOKEN_ADDR --value $VALUE --ref $REF echo "" echo "Press any key to proceed" read enter echo "==== Chain B ====" echo "...Minting" -python -mion ion mint --rpc $IP_B:$PORT_B --account $ACC_B --tkn $TOKEN_ADDR --value 5000 +$PYTHON -mion ion mint --rpc $IP_B:$PORT_B --account $ACC_B --tkn $TOKEN_ADDR --value $VALUE echo "" echo "Press any key to proceed" read enter echo "...Depositing" -python -mion ion deposit --rpc $IP_B:$PORT_B --account $ACC_B --lock $LOCK_ADDR --tkn $TOKEN_ADDR --value 5000 --ref stuff +$PYTHON -mion ion deposit --rpc $IP_B:$PORT_B --account $ACC_B --lock $LOCK_ADDR --tkn $TOKEN_ADDR --value $VALUE --ref $REF echo "" echo "Press any key to proceed" read enter echo "...Fetching proof" -python -mion ion proof --lithium-port $API_PORT_B --account $ACC_B --lock $LOCK_ADDR --tkn $TOKEN_ADDR --value 5000 --ref stuff +$PYTHON -mion ion proof --lithium-port $API_PORT_B --account $ACC_B --lock $LOCK_ADDR --tkn $TOKEN_ADDR --value $VALUE --ref $REF echo "" echo "Press any key to proceed" read enter echo "==== Withdrawing from Chain A ====" -python -mion ion withdraw --lithium-port $API_PORT_B --rpc $IP_A:$PORT_A --account $ACC_B --lock $LOCK_ADDR --tkn $TOKEN_ADDR --value 5000 --ref stuff +$PYTHON -mion ion withdraw --lithium-port $API_PORT_B --rpc $IP_A:$PORT_A --account $ACC_B --lock $LOCK_ADDR --tkn $TOKEN_ADDR --value $VALUE --ref $REF echo "" echo "Press any key to proceed" read enter echo "==== Withdrawing from Chain B ====" -python -mion ion withdraw --lithium-port $API_PORT_A --rpc $IP_B:$PORT_B --account $ACC_A --lock $LOCK_ADDR --tkn $TOKEN_ADDR --value 5000 --ref stuff +$PYTHON -mion ion withdraw --lithium-port $API_PORT_A --rpc $IP_B:$PORT_B --account $ACC_A --lock $LOCK_ADDR --tkn $TOKEN_ADDR --value $VALUE --ref $REF diff --git a/ion/Ion.py b/ion/Ion.py index edc8a10..383549b 100644 --- a/ion/Ion.py +++ b/ion/Ion.py @@ -5,15 +5,18 @@ """ from __future__ import print_function +from binascii import hexlify, unhexlify + import click import requests -import simplejson +from sha3 import keccak_256 from .merkle import merkle_hash -from ethereum.utils import keccak from .ethrpc import BadStatusCodeError, BadJsonError, BadResponseError, ConnectionError from .args import arg_ethrpc, arg_bytes20 #, arg_lithium_api -PRIMITIVE = (int, long, float, str, bool) + + +PRIMITIVE = (int, float, str, bool, bytes) def rpc_call_with_exceptions(function, *args): """ @@ -136,27 +139,22 @@ def ionlock_withdraw(lithium_port, rpc, account, lock, tkn, value, ref): ionlock = rpc.proxy("abi/IonLock.abi", lock, account) token = rpc.proxy("abi/Token.abi", tkn, account) - joined_data = account.encode('hex') + tkn.encode('hex') + lock.encode('hex') + "{0:0{1}x}".format(value,64) + keccak.new(digest_bits=256).update(str(ref)).hexdigest() + joined_data = hexlify(account) + hexlify(tkn) + hexlify(lock) + "{0:0{1}x}".format(value,64).encode('utf-8') + hexlify(keccak_256(ref.encode('utf-8')).digest()) api_url = 'http://127.0.0.1:' + str(lithium_port) - r = requests.post(api_url + "/api/blockid", json={'leaf': joined_data}) - - try: - blockid = r.json()['blockid'] - r = requests.post(api_url + "/api/proof", json={'leaf': joined_data, 'blockid': blockid}) + r = requests.post(api_url + "/api/blockid", json={'leaf': joined_data.decode('ascii')}) - path = r.json()['proof'] - path = [int(x) for x in path] - hashed_ref = keccak.new(digest_bits=256).update(str(ref)).hexdigest() + blockid = r.json()['blockid'] + r = requests.post(api_url + "/api/proof", json={'leaf': joined_data.decode('ascii'), 'blockid': blockid}) - result = rpc_call_with_exceptions(ionlock.Withdraw, value, hashed_ref.decode('hex'), int(blockid), path) - - result = rpc_call_with_exceptions(token.balanceOf, account) - if result is not None: - print("New balance =", result) + path = r.json()['proof'] + path = [int(x) for x in path] + hashed_ref = hexlify(keccak_256(ref.encode('utf-8')).digest()) + result = rpc_call_with_exceptions(ionlock.Withdraw, value, unhexlify(hashed_ref), int(blockid), path) - except simplejson.errors.JSONDecodeError as e: - print(e.message) + result = rpc_call_with_exceptions(token.balanceOf, account) + if result is not None: + print("New balance =", result) return 0 @@ -186,28 +184,24 @@ def ionlink_verify(lithium_port, rpc, account, link, lock, tkn, value, ref): """ ionlink = rpc.proxy("abi/IonLink.abi", link, account) - joined_data = account.encode('hex') + tkn.encode('hex') + lock.encode('hex') + "{0:0{1}x}".format(value,64) + keccak.new(digest_bits=256).update(str(ref)).hexdigest() + joined_data = hexlify(account) + hexlify(tkn) + hexlify(lock) + "{0:0{1}x}".format(value,64).encode('utf-8') + hexlify(keccak_256(ref.encode('utf-8')).digest()) hashed_data = merkle_hash(int(joined_data, 16)) api_url = 'http://127.0.0.1:' + str(lithium_port) - r = requests.post(api_url + "/api/blockid", json={'leaf': joined_data}) + r = requests.post(api_url + "/api/blockid", json={'leaf': joined_data.decode('ascii')}) - try: - blockid = r.json()['blockid'] - r = requests.post(api_url + "/api/proof", json={'leaf': joined_data, 'blockid': blockid}) + blockid = r.json()['blockid'] + r = requests.post(api_url + "/api/proof", json={'leaf': joined_data.decode('ascii'), 'blockid': blockid}) - path = r.json()['proof'] - path = [int(x) for x in path] + path = r.json()['proof'] + path = [int(x) for x in path] - r = requests.post(api_url + "/api/verify", json={'leaf': joined_data, 'proof': path, 'blockid': blockid}) - print("Lithium proof:") - print(r.text) + r = requests.post(api_url + "/api/verify", json={'leaf': joined_data.decode('ascii'), 'proof': path, 'blockid': blockid}) + print("Lithium proof:") + print(r.text) - print("IonLink Proof at block id", blockid) - result = rpc_call_with_exceptions(ionlink.Verify, int(blockid), hashed_data, path) - print(result) - - except simplejson.errors.JSONDecodeError as e: - print(e.message) + print("IonLink Proof at block id", blockid) + result = rpc_call_with_exceptions(ionlink.Verify, int(blockid), hashed_data, path) + print(result) return 0 @@ -231,21 +225,19 @@ def merkle_proof_path(lithium_port, account, lock, tkn, value, ref): :param ref: str, reference used for the deposit :return: 0, merkle path is printed to the console """ - joined_data = account.encode('hex') + tkn.encode('hex') + lock.encode('hex') + "{0:0{1}x}".format(value,64) + keccak.new(digest_bits=256).update(str(ref)).hexdigest() - api_url = 'http://127.0.0.1:' + str(lithium_port) - r = requests.post(api_url + "/api/blockid", json={'leaf': joined_data}) + joined_data = hexlify(account) + hexlify(tkn) + hexlify(lock) + "{0:0{1}x}".format(value,64).encode('utf-8') + hexlify(keccak_256(ref.encode('utf-8')).digest()) + print("Joined data", joined_data) - try: - blockid = r.json()['blockid'] - r = requests.post(api_url + "/api/proof", json={'leaf': joined_data, 'blockid':blockid}) + api_url = 'http://127.0.0.1:' + str(lithium_port) + r = requests.post(api_url + "/api/blockid", json={'leaf': joined_data.decode('ascii')}) - print("Received proof:") - [print("Path ", r.json()['proof'].index(x), " : ", x) for x in r.json()['proof']] + blockid = r.json()['blockid'] + r = requests.post(api_url + "/api/proof", json={'leaf': joined_data.decode('ascii'), 'blockid':blockid}) - print("Latest IonLink block",blockid) + print("Received proof:") + [print("Path ", r.json()['proof'].index(x), " : ", x) for x in r.json()['proof']] - except simplejson.errors.JSONDecodeError as e: - print(e.message) + print("Latest IonLink block",blockid) return 0 @@ -269,19 +261,15 @@ def merkle_verify(proof, lithium_port, account, lock, tkn, value, ref): :param ref: str, reference used for the deposit :return: 0, result is printed to the console """ - joined_data = account.encode('hex') + tkn.encode('hex') + lock.encode('hex') + "{0:0{1}x}".format(value,64) + keccak.new(digest_bits=256).update(str(ref)).hexdigest() + joined_data = hexlify(account) + hexlify(tkn) + hexlify(lock) + "{0:0{1}x}".format(value,64).encode('utf-8') + hexlify(keccak_256(ref.encode('utf-8')).digest()) proof = [int(x) for x in proof] api_url = 'http://127.0.0.1:' + str(lithium_port) - r = requests.post(api_url + "/api/blockid", json={'leaf': joined_data}) - - try: - blockid = r.json()['blockid'] - r = requests.post(api_url + "/api/verify", json={'leaf': joined_data, 'proof': proof, 'blockid':blockid}) - print("Received proof:") - print(r.text) + r = requests.post(api_url + "/api/blockid", json={'leaf': joined_data.decode('ascii')}) - except simplejson.errors.JSONDecodeError as e: - print(e.message) + blockid = r.json()['blockid'] + r = requests.post(api_url + "/api/verify", json={'leaf': joined_data.decode('ascii'), 'proof': proof, 'blockid':blockid}) + print("Received proof:") + print(r.text) return 0 diff --git a/ion/crypto.py b/ion/crypto.py index 031095d..492eb48 100644 --- a/ion/crypto.py +++ b/ion/crypto.py @@ -5,10 +5,9 @@ """ Crypto: Has a load of useful crypto stuff """ +import struct from collections import namedtuple - from ethereum.utils import big_endian_to_int, encode_int32 -from rlp.utils_py2 import ascii_chr from sha3 import keccak_256 @@ -23,6 +22,10 @@ coincurve = None +def ascii_chr(x): + return struct.back('B', x) + + # -------------------------------------------------------------------- # Datatypes diff --git a/ion/ethrpc.py b/ion/ethrpc.py index 101130a..bc32b3c 100644 --- a/ion/ethrpc.py +++ b/ion/ethrpc.py @@ -29,6 +29,8 @@ import requests import time import warnings +from binascii import hexlify, unhexlify +from io import IOBase from collections import namedtuple from ethereum.abi import encode_abi, decode_abi @@ -36,7 +38,7 @@ from requests.exceptions import ConnectionError as RequestsConnectionError from .crypto import keccak_256 -from .utils import require, big_endian_to_int, zpad, encode_int, normalise_address +from .utils import CustomJSONEncoder, require, big_endian_to_int, zpad, encode_int, normalise_address GETH_DEFAULT_RPC_PORT = 8545 ETH_DEFAULT_RPC_PORT = 8545 @@ -76,8 +78,6 @@ class BadResponseError(EthJsonRpcError): pass - - def hex_to_dec(x): ''' Convert hex to decimal @@ -93,7 +93,7 @@ def clean_hex(d): return hex(d).rstrip('L') def validate_block(block): - if isinstance(block, basestring): + if isinstance(block, str): if block not in BLOCK_TAGS: raise ValueError('invalid block tag') if isinstance(block, int): @@ -116,10 +116,23 @@ def ether_to_wei(ether): class EthTransaction(namedtuple('_TxStruct', ('rpc', 'txid'))): + def details(self): + txid = self.txid + if txid[:2] != '0x': + txid = '0x' + txid + return self.rpc.eth_getTransactionByHash(txid) + + def wait(self): + return self.receipt(wait=True) + def receipt(self, wait=False, tick_fn=None): + # TODO: add `timeout` param first = True + txid = self.txid + if txid[:2] != '0x': + txid = '0x' + txid while True: - receipt = self.rpc.eth_getTransactionReceipt(self.txid) + receipt = self.rpc.eth_getTransactionReceipt(txid) # TODO: turn into asynchronous notification / future if receipt: return receipt @@ -127,7 +140,7 @@ def receipt(self, wait=False, tick_fn=None): break try: if first: - if isinstance(wait, callable): + if hasattr(wait, '__call__'): wait() first = False elif tick_fn: @@ -170,7 +183,8 @@ def _call(self, method, params=None, _id=1): url = '{}://{}:{}'.format(scheme, self.host, self.port) headers = {'Content-Type': JSON_MEDIA_TYPE} try: - r = self.session.post(url, headers=headers, data=json.dumps(data)) + encoded_data = json.dumps(data, cls=CustomJSONEncoder) + r = self.session.post(url, headers=headers, data=encoded_data) except RequestsConnectionError: raise ConnectionError(url) if r.status_code / 100 != 2: @@ -202,14 +216,14 @@ def _solproxy_bind(self, method, address, account): ins = [_['type'] for _ in method['inputs']] outs = [_['type'] for _ in method['outputs']] sig = method['name'] + '(' + ','.join(ins) + ')' - # XXX: messy... + if method['constant']: # XXX: document len(outs) and different behaviour... if len(outs) > 1: return lambda *args, **kwa: self.call(address, sig, args, outs, **kwa) return lambda *args, **kwa: self.call(address, sig, args, outs, **kwa)[0] if account is None: - raise RuntimeError("Without account, cannot call non-constant methods") + return None return lambda *args, **kwa: self.call_with_transaction(account, address, sig, args, **kwa) def proxy(self, abi, address, account=None): @@ -223,7 +237,7 @@ def proxy(self, abi, address, account=None): if account is not None: account = normalise_address(account) - if isinstance(abi, file): + if isinstance(abi, IOBase): abi = json.load(abi) elif isinstance(abi, str): with open(abi) as jsonfile: @@ -240,7 +254,7 @@ def proxy(self, abi, address, account=None): continue sig = "%s(%s)" % (method['name'], ','.join([i['type'] for i in method['inputs']])) - sig_hash = keccak_256(bytes(sig)).hexdigest()[:8] + sig_hash = keccak_256(sig.encode('utf-8')).hexdigest()[:8] # Provide an alternate, where the explicit function signature proxy[method['name']] = handler @@ -252,6 +266,21 @@ def proxy(self, abi, address, account=None): # high-level methods ################################################################################ + def receipt(self, txid, wait=False, raise_on_error=False): + # TODO: add `timeout` param + transaction = EthTransaction(self, txid) + receipt = transaction.receipt(wait=wait) + if raise_on_error: + if int(receipt['status'], 16) == 0: + raise EthJsonRpcError("Transaction was aborted") + return receipt + + def receipt_wait(self, txid, raise_on_error=True): + """ + Wait for the transaction to be mined, then return receipt + """ + return self.receipt(txid, raise_on_error) + def transfer(self, from_, to, amount): ''' Send wei from one address to another @@ -267,7 +296,7 @@ def create_contract(self, from_, code, gas, sig=None, args=None): if sig is not None and args is not None: types = sig[sig.find('(') + 1: sig.find(')')].split(',') encoded_params = encode_abi(types, args) - code += encoded_params.encode('hex') + code += hexlify(encoded_params) return self.eth_sendTransaction(from_address=from_, gas=gas, data=code) def get_contract_address(self, tx): @@ -283,12 +312,12 @@ def call(self, address, sig, args, result_types): transaction (useful for reading data) ''' data = self._encode_function(sig, args) - data_hex = data.encode('hex') + data_hex = hexlify(data) response = self.eth_call(to_address=address, data=data_hex) # XXX: horrible hack for when RPC returns '0x0'... if (len(result_types) == 0 or result_types[0] == 'uint256') and response == '0x0': response = '0x' + ('0' * 64) - return decode_abi(result_types, response[2:].decode('hex')) + return decode_abi(result_types, unhexlify(response[2:])) def call_with_transaction(self, from_, address, sig, args, gas=None, gas_price=None, value=None): ''' @@ -298,7 +327,7 @@ def call_with_transaction(self, from_, address, sig, args, gas=None, gas_price=N gas = gas or self.DEFAULT_GAS_PER_TX gas_price = gas_price or self.DEFAULT_GAS_PRICE data = self._encode_function(sig, args) - data_hex = data.encode('hex') + data_hex = hexlify(data) return self.eth_sendTransaction(from_address=from_, to_address=address, data=data_hex, gas=gas, gas_price=gas_price, value=value) @@ -320,7 +349,7 @@ def web3_sha3(self, data): TESTED ''' - data = str(data).encode('hex') + data = hexlify(str(data)) return self._call('web3_sha3', [data]) def net_version(self): @@ -479,7 +508,7 @@ def eth_getCode(self, address, default_block=BLOCK_TAG_LATEST): NEEDS TESTING ''' - if isinstance(default_block, basestring): + if isinstance(default_block, str): if default_block not in BLOCK_TAGS: raise ValueError return self._call('eth_getCode', [address, default_block]) @@ -500,13 +529,13 @@ def eth_sendTransaction(self, to_address=None, from_address=None, gas=None, gas_ NEEDS TESTING ''' if len(to_address) == 20: - to_address = to_address.encode('hex') + to_address = hexlify(to_address) if len(from_address) == 20: - from_address = from_address.encode('hex') + from_address = hexlify(from_address) params = {} - params['from'] = from_address or self.eth_coinbase() + params['from'] = normalise_address(from_address) or self.eth_coinbase() if to_address is not None: - params['to'] = to_address + params['to'] = normalise_address(to_address) if gas is not None: params['gas'] = hex(gas) if gas_price is not None: @@ -514,7 +543,7 @@ def eth_sendTransaction(self, to_address=None, from_address=None, gas=None, gas_ if value is not None: params['value'] = clean_hex(value) if data is not None: - params['data'] = data + params['data'] = data.decode('utf-8') if nonce is not None: params['nonce'] = hex(nonce) txid = self._call('eth_sendTransaction', [params]) @@ -535,17 +564,17 @@ def eth_call(self, to_address, from_address=None, gas=None, gas_price=None, valu NEEDS TESTING ''' - if isinstance(default_block, basestring): + if isinstance(default_block, str): if default_block not in BLOCK_TAGS: raise ValueError if from_address is not None and len(from_address) == 20: - from_address = from_address.encode('hex') + from_address = hexlify(from_address) if len(to_address) == 20: - to_address = to_address.encode('hex') + to_address = hexlify(to_address) obj = {} - obj['to'] = to_address + obj['to'] = normalise_address(to_address) if from_address is not None: - obj['from'] = from_address + obj['from'] = normalise_address(from_address) if gas is not None: obj['gas'] = hex(gas) if gas_price is not None: @@ -553,7 +582,7 @@ def eth_call(self, to_address, from_address=None, gas=None, gas_price=None, valu if value is not None: obj['value'] = value if data is not None: - obj['data'] = data + obj['data'] = data.decode('utf-8') return self._call('eth_call', [obj, default_block]) def eth_estimateGas(self, to_address=None, from_address=None, gas=None, gas_price=None, value=None, data=None, @@ -563,14 +592,14 @@ def eth_estimateGas(self, to_address=None, from_address=None, gas=None, gas_pric NEEDS TESTING ''' - if isinstance(default_block, basestring): + if isinstance(default_block, str): if default_block not in BLOCK_TAGS: raise ValueError obj = {} if to_address is not None: - obj['to'] = to_address + obj['to'] = normalise_address(to_address) if from_address is not None: - obj['from'] = from_address + obj['from'] = normalise_address(from_address) if gas is not None: obj['gas'] = hex(gas) if gas_price is not None: diff --git a/ion/htlc/cli.py b/ion/htlc/cli.py index 4655555..5a707c1 100644 --- a/ion/htlc/cli.py +++ b/ion/htlc/cli.py @@ -12,75 +12,19 @@ from .common import get_random_secret_32, get_default_expiry, make_htlc_proxy -####################################################################### -# -# Command-line interface to the HTLC contract -# -# $ ion htlc contract [options] sub-command [sub-options] -# -# e.g. -# -# $ ion htlc contract --account X --contract Y deposit --receiver Z ... -# - - -# TODO: add value... -@click.command() -@click.pass_obj -@click.option('--receiver', callback=arg_bytes20, metavar="0x...20", required=True, help="Receiver address") -@click.option('--secret', callback=arg_bytes32, metavar="0x...32", default=get_random_secret_32, help="Secret to be supplied upon withdraw") -@click.option('--amount', callback=arg_uint256, metavar='wei', help='Amount of WEI to deposit') -@click.option('--expires', metavar="seconds|unixtime", callback=arg_expiry, type=int, default=get_default_expiry, help="Expiry time, as duration (seconds), or UNIX epoch") -def contract_deposit(contract, receiver, secret, amount, expires): - now = int(time.time()) - print("Expires in", expires - now, "seconds") - - # TODO: verify balance for account is above or equal to `amount` - - image = sha256(secret).digest() # the hash pre-image is the 'secret' - contract.Deposit(receiver, image, expires, value=amount) - - -@click.command() -@click.pass_obj -@click.option('--secret', callback=arg_bytes32, metavar="0x...32", required=True, help="Exchange ID") -def contract_withdraw(contract, secret): - image = sha256(secret).digest() # the hash pre-image is the 'secret' - contract.Withdraw(image, secret) - - -@click.command() -@click.pass_obj -@click.option('--image', callback=arg_bytes32, metavar="0x...32", required=True, help="Exchange hash image") -def contract_refund(contract, image): - contract.Refund(image) - - -@click.group('contract', help='Command-line interface to Ethereum HTLC contract') -@click.pass_context -@click.option('--rpc', callback=arg_ethrpc, metavar="ip:port", default='127.0.0.1:8545', help="Ethereum JSON-RPC server") -@click.option('--account', callback=arg_bytes20, metavar="0x...20", required=True, help="Account to transfer from.") -@click.option('--contract', callback=arg_bytes20, metavar="0x...20", required=True, help="HTLC contract address") -def contract_multicommand(ctx, rpc, account, contract): - # Contract will get passed to sub-commands as first object when using `@click.pass_obj` - ctx.obj = make_htlc_proxy(rpc, contract, account) - - -contract_multicommand.add_command(contract_deposit, "deposit") -contract_multicommand.add_command(contract_withdraw, "withdraw") -contract_multicommand.add_command(contract_refund, "refund") - ####################################################################### # -# Multi-command entry-point +# HTLC coordinator server # @click.command() @click.option('--contract', callback=arg_bytes20, metavar="0x...20", required=True, help="HTLC contract address") -def coordinator(contract): - from .coordinator import main - main(contract) +@click.option('--rpc', callback=arg_ethrpc, metavar="ip:port", default='127.0.0.1:8545', help="Ethereum JSON-RPC server") +@click.option('--account', callback=arg_bytes20, metavar="0x...20", required=False, help="Account to transfer from.") +def coordinator(contract, rpc, account): + from .coordinator import main as coordinator_main + return coordinator_main(contract, rpc) ####################################################################### @@ -89,7 +33,6 @@ def coordinator(contract): # COMMANDS = click.Group("htlc", help="Hash-Time-Lock Atomic Swap") -COMMANDS.add_command(contract_multicommand, 'contract') COMMANDS.add_command(coordinator, 'coordinator') diff --git a/ion/htlc/common.py b/ion/htlc/common.py index 05d93de..136e206 100644 --- a/ion/htlc/common.py +++ b/ion/htlc/common.py @@ -12,7 +12,12 @@ DEFAULT_EXPIRY_DURATION = 10 * ONE_MINUTE MINIMUM_EXPIRY_DURATION = 2 * ONE_MINUTE -def make_htlc_proxy(rpc, contract, account): + +class ExchangeError(Exception): + pass + + +def make_htlc_proxy(rpc, contract, account=None): """ TODO: embed 'abi/HTLC.abi' file in package resources? """ diff --git a/ion/htlc/coordclient.py b/ion/htlc/coordclient.py index fde5431..178bd93 100644 --- a/ion/htlc/coordclient.py +++ b/ion/htlc/coordclient.py @@ -3,6 +3,7 @@ import os from hashlib import sha256 +from binascii import hexlify, unhexlify from ..utils import require, normalise_address from ..ethrpc import EthJsonRpc @@ -60,7 +61,7 @@ def confirm(self, wait=True): # Offerer deposits their side of the deal, locked to same hashed secret htlc_address = exch_data['offer_htlc_address'] htlc_contract = make_htlc_proxy(ethrpc, htlc_address, my_address) - txn = htlc_contract.Deposit(conf_receiver, conf_secret_hashed.decode('hex'), conf_expiry, value=conf_value) + txn = htlc_contract.Deposit(conf_receiver, unhexlify(conf_secret_hashed), conf_expiry, value=conf_value) receipt = txn.receipt(wait=wait) if receipt and int(receipt['status'], 16) == 0: @@ -83,22 +84,19 @@ def release(self, secret, wait=True): my_address = self._coordapi.my_address exch_data = self._exch_obj.data - secret_hex = secret.encode('hex') + secret_hex = hexlify(secret).decode('ascii') secret_hashed = sha256(secret).digest() - secret_hashed_hex = secret_hashed.encode('hex') + secret_hashed_hex = hexlify(secret_hashed).decode('ascii') require(my_address == self._data['depositor'], "Only proposer can release") require(self._data['secret_hashed'] == secret_hashed_hex, "Secrets don't match!") - exch_guid = self._data['taker_guid'].decode('hex') + exch_guid = unhexlify(self._data['taker_guid']) htlc_address = exch_data['offer_htlc_address'] htlc_contract = make_htlc_proxy(ethrpc, htlc_address, my_address) txn = htlc_contract.Withdraw(exch_guid, secret) - - receipt = txn.receipt(wait=wait) - if receipt and int(receipt['status'], 16) == 0: - raise RuntimeError("Release failed, txn: " + txn.txid) + txn.wait() # Reveal secret, posting back to server self._resource.release.POST( @@ -116,17 +114,17 @@ def finish(self, wait=True): exch_data = self._exch_obj.data secret_hex = self._data['secret'] - secret = secret_hex.decode('hex') + secret = unhexlify(secret_hex) # TODO: verify secret hashes to hashed image - exch_guid = self._data['offer_guid'].decode('hex') + exch_guid = unhexlify(self._data['offer_guid']) htlc_address = exch_data['want_htlc_address'] htlc_contract = make_htlc_proxy(ethrpc, htlc_address, my_address) txn = htlc_contract.Withdraw(exch_guid, secret) - receipt = txn.receipt(wait=wait) + receipt = txn.wait() if receipt and int(receipt['status'], 16) == 0: raise RuntimeError("Finish failed, txn: " + txn.txid) @@ -146,7 +144,7 @@ def refund(self): secret_hashed = secret_hashed_hex.decode('hex') # TODO: detertmine which side we're on, automagically call correct one - if x: + if False: htlc_address = self._data['depositor'] else: htlc_address = exch_data['want_htlc_address'] @@ -191,6 +189,7 @@ def chosen_proposal(self): prop_id = self._data['chosen_proposal'] if prop_id: return self.proposal(prop_id) + return None @property def proposals(self): @@ -199,7 +198,7 @@ def proposals(self): def proposal(self, secret_hashed_hex): return self.proposals[secret_hashed_hex] - def propose(self, wait=True): + def propose(self): """ Submit a proposal for the exchange by depositing your tokens into a HTLC contract. @@ -207,7 +206,7 @@ def propose(self, wait=True): # Create a random secret secret = os.urandom(32) secret_hashed = sha256(secret).digest() - secret_hashed_hex = secret_hashed.encode('hex') + secret_hashed_hex = hexlify(secret_hashed).decode('ascii') # Proposal parameters prop_receiver = self._data['offer_address'] @@ -220,28 +219,26 @@ def propose(self, wait=True): # TODO: verify adequate balance to cover the deposit - # XXX: for testing, we can be both sides... - #require(my_address != prop_receiver, "Cannot be both sides of exchange") + require(my_address != prop_receiver, "Cannot be both sides of exchange") htlc_address = self._data['want_htlc_address'] htlc_contract = make_htlc_proxy(ethrpc, htlc_address, my_address) txn = htlc_contract.Deposit(prop_receiver, secret_hashed, prop_expiry, value=prop_value) - receipt = txn.receipt(wait=wait) + receipt = txn.receipt(wait=True) if receipt and int(receipt['status'], 16) == 0: raise RuntimeError("Propose deposit failed, txn: " + txn.txid) # Notify coordinator of proposal proposal_resource = self._resource(secret_hashed_hex) - response = proposal_resource.POST( + propdata = proposal_resource.POST( expiry=prop_expiry, depositor=my_address, txid=txn.txid ) - require(response['ok'] == 1, "Proposal coordinator API error") # Add proposal to list, then return it - proposal = self._make_proposal(secret_hashed_hex) + proposal = self._make_proposal(secret_hashed_hex, propdata) self.proposals[secret_hashed_hex] = proposal return secret, proposal diff --git a/ion/htlc/coordinator.py b/ion/htlc/coordinator.py index 25087e1..d83fdac 100644 --- a/ion/htlc/coordinator.py +++ b/ion/htlc/coordinator.py @@ -2,85 +2,15 @@ ## SPDX-License-Identifier: LGPL-3.0+ import sys -import os -import time -from hashlib import sha256 -from flask import Flask, Blueprint, request, abort, jsonify, make_response -from werkzeug.routing import BaseConverter +from flask import Flask, Blueprint, request, jsonify -from ..args import arg_bytes32, arg_bytes20, arg_uint256 -from ..utils import scan_bin +from ..ethrpc import EthJsonRpc +from ..utils import normalise_address +from ..webutils import (Bytes32Converter, Bytes20Converter, params_parse, api_abort, + param_bytes20, param_bytes32, param_uint256) -from .common import MINIMUM_EXPIRY_DURATION - - -####################################################################### -# TODO: move to ..webutils or something - -def api_abort(message, code=400): - return abort(make_response(jsonify(dict(_error=message)), code)) - - -def param(the_dict, key): - if key not in the_dict: - return api_abort("Parameter required: " + key) - return the_dict[key] - - -def param_filter_arg(the_dict, key, filter_fn): - """ - Applies a click argument filter from `..args` to a value from a dictionary - Does the things with HTTP errors etc. upon failure. - """ - value = param(the_dict, key) - try: - value = filter_fn(None, None, value) - except Exception as ex: - return api_abort("Invalid parameter '%s' - %s" % (key, str(ex))) - return value - - -def param_bytes32(the_dict, key): - return param_filter_arg(the_dict, key, arg_bytes32).encode('hex') - - -def param_bytes20(the_dict, key): - return param_filter_arg(the_dict, key, arg_bytes20).encode('hex') - - -def param_uint256(the_dict, key): - return param_filter_arg(the_dict, key, arg_uint256) - - -class BytesConverter(BaseConverter): - """ - Accepts hex encoded bytes as an argument - Provides raw bytes to Python - Marshals between raw bytes and hex encoded - """ - BYTES_LEN = None - def __init__(self, url_map, *items): - assert self.BYTES_LEN is not None - super(BytesConverter, self).__init__(url_map) - self.regex = '(0x)?[a-fA-F0-9]{' + str(self.BYTES_LEN * 2) + '}' - - def to_python(self, value): - # Normalise hex encoding... - return scan_bin(value).encode('hex') - - -class Bytes32Converter(BytesConverter): - """Accept 32 hex-encoded bytes as URL param""" - BYTES_LEN = 32 - - -class Bytes20Converter(BytesConverter): - """Accept 20 hex-encoded bytes as URL param""" - BYTES_LEN = 20 - - -####################################################################### +from .manager import ExchangeManager, ExchangeError class CoordinatorBlueprint(Blueprint): @@ -88,11 +18,10 @@ class CoordinatorBlueprint(Blueprint): Provides a web API for coordinating cross-chain HTLC exchanges """ - def __init__(self, htlc_address, **kwa): + def __init__(self, htlc_address, rpc, **kwa): Blueprint.__init__(self, 'htlc', __name__, **kwa) - self._htlc_address = htlc_address - self._exchanges = dict() + self._manager = ExchangeManager(htlc_address, rpc) # XXX: This sure looks hacky... assignment not allowed in lambda, callback etc. self.record(lambda s: s.app.url_map.converters.__setitem__('bytes32', Bytes32Converter)) @@ -113,23 +42,11 @@ def __init__(self, htlc_address, **kwa): self.add_url_rule("///finish", 'finish', self.exch_finish, methods=['POST']) - def _get_exch(self, exch_id): - if exch_id not in self._exchanges: - return api_abort("Unknown exchange", code=404) - return self._exchanges[exch_id] - - def _get_proposal(self, exch_id, secret_hashed): - exch = self._get_exch(exch_id) - proposal = exch['proposals'].get(secret_hashed) - if not proposal: - return api_abort("Unknown proposal", code=404) - return exch, proposal - def index(self): """ Display list of all exchanges, and their details """ - return jsonify(self._exchanges) + return jsonify(self._manager.exchanges) def exch_advertise(self): """ @@ -138,33 +55,20 @@ def exch_advertise(self): This is performed by Alice """ - # Parse and validate input parameters - offer_address = param_bytes20(request.form, 'offer_address') - offer_amount = param_uint256(request.form, 'offer_amount') - want_amount = param_uint256(request.form, 'want_amount') - - # TODO: validate contract addresses etc. and verify on-chain stuff - - exch_id = os.urandom(20).encode('hex') - - # Save exchange details - # TODO: replace with class instance, `Exchange` ? - self._exchanges[exch_id] = dict( - guid=exch_id, - offer_address=offer_address, - offer_amount=offer_amount, - want_amount=want_amount, - proposals=dict(), - chosen_proposal=None, - - # Temporary placeholders - # TODO: replace with correct contracts - offer_htlc_address=self._htlc_address.encode('hex'), - want_htlc_address=self._htlc_address.encode('hex') - ) + + params = params_parse(request.form, dict( + offer_address=param_bytes20, + offer_amount=param_uint256, + want_amount=param_uint256 + )) + + try: + exch_guid = self._manager.advertise(**params) + except ExchangeError as ex: + return api_abort(str(ex)) return jsonify(dict( - id=exch_id, + id=exch_guid, ok=1 )) @@ -172,7 +76,10 @@ def exch_get(self, exch_id): """ Retrieve details of exchange """ - exch = self._get_exch(exch_id) + try: + exch = self._manager.get_exchange(exch_id) + except ExchangeError as ex: + return api_abort(str(ex)) return jsonify(exch) def exch_propose(self, exch_id, secret_hashed): @@ -183,54 +90,28 @@ def exch_propose(self, exch_id, secret_hashed): This is performed by Bob """ - exch = self._get_exch(exch_id) - - if exch['chosen_proposal']: - return api_abort("Proposal has already been chosen", code=409) - - # Hashed secret is the 'image', pre-image can be supplied to prove knowledge of secret - if secret_hashed in exch['proposals']: - return api_abort("Duplicate proposal secret", code=409) - - # TODO: verify either side of the exchange aren't the same - - expiry = param_uint256(request.form, 'expiry') - depositor = param_bytes20(request.form, 'depositor') - - # TODO: verify details on-chain, expiry, depositor and secret must match - - # Verify expiry time is acceptable - # XXX: should minimum expiry be left to the contract, or the coordinator? - now = int(time.time()) - min_expiry = now + MINIMUM_EXPIRY_DURATION - if expiry < min_expiry: - return api_abort("Expiry too short") - - # GUID used for the exchanges - # offer_guid = Deposit() by B (the proposer) - offer_guid = sha256(exch['offer_address'].decode('hex') + secret_hashed.decode('hex')).digest() - # taker_guid = Deposit() by A (the initial offerer) - taker_guid = sha256(depositor.decode('hex') + secret_hashed.decode('hex')).digest() + params = params_parse(request.form, dict( + expiry=param_uint256, + depositor=param_bytes20, + txid=param_bytes32, + )) - # Store proposal - exch['proposals'][secret_hashed] = dict( - secret_hashed=secret_hashed, - expiry=expiry, - depositor=depositor, - offer_guid=offer_guid.encode('hex'), - taker_guid=taker_guid.encode('hex'), - ) + try: + _, proposal = self._manager.propose(exch_id, secret_hashed, **params) + except ExchangeError as ex: + return api_abort(str(ex)) # TODO: redirect to proposal URL? - or avoid another GET request... - return jsonify(dict( - ok=1 - )) + return jsonify(proposal) def exch_proposal_get(self, exch_id, secret_hashed): """ Retrieve details for a specific exchange proposal """ - exch, proposal = self._get_proposal(exch_id, secret_hashed) + try: + _, proposal = self._manager.get_proposal(exch_id, secret_hashed) + except ExchangeError as ex: + return api_abort(str(ex)) return jsonify(proposal) def exch_confirm(self, exch_id, secret_hashed): @@ -241,13 +122,16 @@ def exch_confirm(self, exch_id, secret_hashed): This is performed by Alice """ - exch, proposal = self._get_proposal(exch_id, secret_hashed) - - # XXX: one side of the expiry must be longer than the other to handle failure case - # TODO: verify on-chain details match the proposal + params = params_parse(request.form, dict( + txid=param_bytes32, + )) - exch['chosen_proposal'] = secret_hashed + try: + self._manager.confirm(exch_id, secret_hashed, **params) + except ExchangeError as ex: + return api_abort(str(ex)) + # TODO: return updated proposal object return jsonify(dict( ok=1 )) @@ -258,17 +142,15 @@ def exch_release(self, exch_id, secret_hashed): This is performed by Bob """ - exch, proposal = self._get_proposal(exch_id, secret_hashed) - - secret_hex = param_bytes32(request.form, 'secret') - secret = secret_hex.decode('hex') - secret_hashed_check = sha256(secret).digest() - secret_hashed_check_hex = secret_hashed_check.encode('hex') - - if secret_hashed_check_hex != secret_hashed: - return api_abort(' '.join(["Secret doesn't match! Got", secret_hashed_check_hex, 'expected', secret_hashed])) + params = params_parse(request.form, dict( + secret=param_bytes32, + txid=param_bytes32, + )) - proposal['secret'] = secret_hex + try: + self._manager.release(exch_id, secret_hashed, **params) + except ExchangeError as ex: + return api_abort(str(ex)) return jsonify(dict( ok=1 @@ -282,34 +164,37 @@ def exch_finish(self, exch_id, secret_hashed): This completes the exchange. """ - exch, proposal = self._get_proposal(exch_id, secret_hashed) + params = params_parse(request.form, dict( + txid=param_bytes32, + )) - # XXX: technically a web API call isn't necessary for this step - # the API should monitor the state of both sides of the exchange - # and update the status / information automagically + try: + self._manager.finish(exch_id, secret_hashed, **params) + except ExchangeError as ex: + return api_abort(str(ex)) return jsonify(dict( ok=1 )) -def main(htlc_address): +def main(htlc_address, rpc=None): """ Development server for coordinator NOTE: not suitable for 'production' SEE: http://flask.pocoo.org/docs/1.0/deploying/#deployment """ - if len(htlc_address) != 20: - htlc_address = scan_bin(htlc_address) - print("HTLC address:", htlc_address.encode('hex')) + htlc_address = normalise_address(htlc_address) + if rpc is None: + rpc = EthJsonRpc() + + coordinator = CoordinatorBlueprint(htlc_address, rpc) - coordinator = CoordinatorBlueprint(htlc_address) app = Flask(__name__) - # app.debug = 1 app.register_blueprint(coordinator, url_prefix='/htlc') - # NOTE: Flask reloader is DAF, doesn't work well with packages *shakes-fists* + # NOTE: Flask reloader doesn't work well with packages *shakes-fists* app.run(use_reloader=False) return 0 diff --git a/ion/htlc/manager.py b/ion/htlc/manager.py new file mode 100644 index 0000000..c7a9647 --- /dev/null +++ b/ion/htlc/manager.py @@ -0,0 +1,151 @@ +## Copyright (c) 2018 Harry Roberts. All Rights Reserved. +## SPDX-License-Identifier: LGPL-3.0+ + +import os +import time +from binascii import hexlify, unhexlify +from hashlib import sha256 + +from ..utils import normalise_address +from ..ethrpc import EthJsonRpc + +from .common import MINIMUM_EXPIRY_DURATION, make_htlc_proxy, ExchangeError +from .verify import verify_deposit + + +class ExchangeManager(object): + def __init__(self, htlc_address, ethrpc): + self._exchanges = dict() + self._htlc_address = normalise_address(htlc_address) + self._rpc = ethrpc + assert isinstance(ethrpc, EthJsonRpc) + + @property + def exchanges(self): + return self._exchanges + + def get_exchange(self, exch_guid): + return self._exchanges.get(exch_guid) + + def get_proposal(self, exch_guid, secret_hashed): + exch = self.get_exchange(exch_guid) + proposal = exch['proposals'].get(secret_hashed, None) + if not proposal: + raise ExchangeError("Unknown proposal") + return exch, proposal + + def advertise(self, **kwa): + exch_guid = hexlify(os.urandom(20)).decode('ascii') + + exch = dict( + guid=exch_guid, + offer_address=kwa['offer_address'], + offer_amount=kwa['offer_amount'], + want_amount=kwa['want_amount'], + proposals=dict(), + chosen_proposal=None, + + # Temporary placeholders + # TODO: replace with correct contracts depending on network + offer_htlc_address=self._htlc_address, + want_htlc_address=self._htlc_address + ) + + self._exchanges[exch_guid] = exch + + return exch_guid + + def propose(self, exch_guid, secret_hashed, **kwa): + exch = self.get_exchange(exch_guid) + + expiry = kwa['expiry'] + depositor = kwa['depositor'] + + if exch['chosen_proposal']: + raise ExchangeError("Proposal has already been chosen") + + # Hashed secret is the 'image', pre-image can be supplied to prove knowledge of secret + if secret_hashed in exch['proposals']: + raise ExchangeError("Duplicate proposal secret") + + # GUID used for the exchanges + # offer_guid = Deposit() by B (the proposer) + offer_guid = sha256(unhexlify(exch['offer_address']) + unhexlify(secret_hashed)).digest() + # taker_guid = Deposit() by A (the initial offerer) + taker_guid = sha256(unhexlify(depositor) + unhexlify(secret_hashed)).digest() + + # Wait for transaction success + txid = kwa['txid'] + + proposal = dict( + secret_hashed=secret_hashed, + expiry=expiry, + depositor=depositor, + offer_guid=hexlify(offer_guid).decode('ascii'), + taker_guid=hexlify(taker_guid).decode('ascii'), + propose_txid=txid, + ) + + verify_deposit('proposer', self._rpc, exch, proposal, txid) + + exch['proposals'][secret_hashed] = proposal + return exch, proposal + + def confirm(self, exch_guid, secret_hashed, **kwa): + exch, proposal = self.get_proposal(exch_guid, secret_hashed) + + txid = kwa['txid'] + + verify_deposit('confirmer', self._rpc, exch, proposal, txid) + + proposal['confirm_txid'] = txid + exch['chosen_proposal'] = secret_hashed + + + def release(self, exch_guid, secret_hashed, **kwa): + exch, proposal = self.get_proposal(exch_guid, secret_hashed) + + secret_hex = kwa['secret'] + secret = unhexlify(secret_hex) + secret_hashed_check = sha256(secret).digest() + secret_hashed_check_hex = hexlify(secret_hashed_check).decode('ascii') + if secret_hashed_check_hex != secret_hashed: + raise ExchangeError(' '.join(["Secret doesn't match! Got", secret_hashed_check_hex, 'expected', secret_hashed])) + + # Wait for transaction success + txid = kwa['txid'] + self._rpc.receipt_wait(txid) + + contract = make_htlc_proxy(self._rpc, exch['want_htlc_address']) + # XXX: if the server errors out here... then proposal won't get updated, this is bad! + + # 2 = Withdrawn + offer_guid = unhexlify(proposal['offer_guid']) + onchain_state = contract.GetState(offer_guid) + print("After release, State is ", onchain_state) + """ + # XXX: even though we've waited for a successful receipt, the state is still `1` + # but in the 'finish' call, the state with the same params is `2` + if onchain_state != 2: + raise ExchangeError("Exchange is in wrong state") + """ + + proposal['secret'] = secret_hex + proposal['release_txid'] = txid + + def finish(self, exch_guid, secret_hashed, **kwa): + exch, proposal = self.get_proposal(exch_guid, secret_hashed) + + contract = make_htlc_proxy(self._rpc, exch['offer_htlc_address']) + + taker_guid = unhexlify(proposal['taker_guid']) + + txid = kwa['txid'] + self._rpc.receipt_wait(txid) + + # 2 = Withdrawn + onchain_state = contract.GetState(taker_guid) + if onchain_state != 2: + raise ExchangeError("Exchange is in wrong state") + + proposal['finish_txid'] = txid diff --git a/ion/htlc/verify.py b/ion/htlc/verify.py new file mode 100644 index 0000000..2afee5e --- /dev/null +++ b/ion/htlc/verify.py @@ -0,0 +1,84 @@ +## Copyright (c) 2018 Harry Roberts. All Rights Reserved. +## SPDX-License-Identifier: LGPL-3.0+ + +import time +from binascii import hexlify, unhexlify + +from ..utils import normalise_address, require + +from .common import MINIMUM_EXPIRY_DURATION, make_htlc_proxy + + +def verify_deposit(side, rpc, exch, proposal, txid): + """ + Verifies that the contract deposit matches the exchange and proposal + """ + require(side in ['proposer', 'confirmer'], "Side must be 'proposer' or 'confirmer'") + + expiry = proposal['expiry'] + secret_hashed = proposal['secret_hashed'] + + # Verification logic is the same, but uses different parameters + # depending on which side, the proposer or the confirmer + if side == 'proposer': + deposit_guid = unhexlify(proposal['offer_guid']) + htlc_address = exch['want_htlc_address'] + expected_amount = exch['want_amount'] + expected_receiver = exch['offer_address'] + expected_sender = proposal['depositor'] + else: + deposit_guid = unhexlify(proposal['taker_guid']) + htlc_address = exch['offer_htlc_address'] + expected_amount = exch['offer_amount'] + expected_receiver = proposal['depositor'] + expected_sender = exch['offer_address'] + + rpc.receipt_wait(txid) + + contract = make_htlc_proxy(rpc, htlc_address) + + # Verify expiry time is acceptable + # XXX: should minimum expiry be left to the contract, or the coordinator? + now = int(time.time()) + min_expiry = now + MINIMUM_EXPIRY_DURATION + if expiry < min_expiry: + raise ExchangeError("Expiry too short, got %d expected >= %d" % ( + expiry, min_expiry)) + + # Verify on-chain expiry matches + onchain_expiry = contract.GetExpiry(deposit_guid) + if expiry != onchain_expiry: + raise ExchangeError("Expiry doesn't match contract, got %d expected %d" % ( + expiry, onchain_expiry)) + + # Verify on-chain hashed secret + onchain_sechash = hexlify(contract.GetSecretHashed(deposit_guid)).decode('ascii') + if onchain_sechash != secret_hashed: + raise ExchangeError("Hashed secret doesn't match contract, got %s expected %s" % ( + onchain_sechash, secret_hashed)) + + # 1 = Deposited + onchain_state = contract.GetState(deposit_guid) + if onchain_state != 1: + raise ExchangeError("Exchange is in wrong state, got %d expected %d" % ( + onchain_state, 1)) + + # Verify receiver + onchain_receiver = normalise_address(contract.GetReceiver(deposit_guid)) + if onchain_receiver != expected_receiver: + raise ExchangeError("Wrong receiver address, got %s expected %s" % ( + onchain_receiver, expected_receiver)) + + # Verify sender + onchain_sender = normalise_address(contract.GetSender(deposit_guid)) + if onchain_sender != expected_sender: + raise ExchangeError("Wrong sender address, got %s expected %s" % ( + onchain_sender, expected_sender)) + + # Ensure deposited amount is more or greater than what was wanted + onchain_amount = contract.GetAmount(deposit_guid) + if onchain_amount < expected_amount: + raise ExchangeError("Propose amount differs from want amount, got %d expected %d" % ( + onchain_amount, expected_amount)) + + return True \ No newline at end of file diff --git a/ion/lithium/__init__.py b/ion/lithium/__init__.py index 8819ca2..e69de29 100644 --- a/ion/lithium/__init__.py +++ b/ion/lithium/__init__.py @@ -1 +0,0 @@ -from .lithium import etheventrelay \ No newline at end of file diff --git a/ion/lithium/api.py b/ion/lithium/api.py index 607741a..82bdccd 100644 --- a/ion/lithium/api.py +++ b/ion/lithium/api.py @@ -9,6 +9,7 @@ which is required when withdrawing funds from IonLock """ +from binascii import hexlify, unhexlify from flask import Flask, request, jsonify # from flask import Flask, url_for @@ -16,6 +17,7 @@ app = Flask(__name__) +app.config['JSON_AS_ASCII'] = False @app.route('/') @@ -33,15 +35,15 @@ def api_leaves(): if blockid is not None: nleaves = app.lithium.checkpoints[blockid] byte_leaves = app.lithium.leaves[0:nleaves] - hex_leaves = [x.encode('hex') for x in byte_leaves] + hex_leaves = [hexlify(x) for x in byte_leaves] elif request.method == 'GET': byte_leaves = app.lithium.leaves - hex_leaves = [x.encode('hex') for x in byte_leaves] + hex_leaves = [hexlify(x) for x in byte_leaves] - dict = {u'leaves': hex_leaves} - - return jsonify(dict) + # XXX: python3, json.dumps doesn't handle `bytes` very well.... + data = {u'leaves': [_.decode('utf-8') for _ in hex_leaves]} + return jsonify(data) @app.route('/api/root', methods=['GET']) def api_root(): @@ -50,8 +52,7 @@ def api_root(): """ byte_leaves = app.lithium.leaves tree, root = merkle_tree(byte_leaves) - dict = {u'root': root} - return jsonify(dict) + return jsonify({u'root': root}) @app.route('/api/checkpoints', methods=['GET']) def api_checkpoint(): @@ -66,9 +67,9 @@ def api_blockid(): """ if request.method == 'POST': json = request.get_json() - leaf = json[u'leaf'] + leaf = json[u'leaf'].encode('utf-8') - hex_leaves = [x.encode('hex') for x in app.lithium.leaves] + hex_leaves = [hexlify(x) for x in app.lithium.leaves] byte_checkpoints = app.lithium.checkpoints if leaf is not None: @@ -81,8 +82,7 @@ def api_blockid(): blockid = block break - dict = {u'blockid': str(blockid)} - return jsonify(dict) + return jsonify({u'blockid': str(blockid)}) else: return "No valid leaf received." @@ -104,7 +104,7 @@ def api_proof(): nleaves = app.lithium.checkpoints[blockid] tree, root = merkle_tree(app.lithium.leaves[:nleaves]) - hex_leaf = leaf.decode('hex') + hex_leaf = unhexlify(leaf) path = merkle_path(hex_leaf, tree) @@ -133,7 +133,7 @@ def api_verify_proof(): leaves = app.lithium.leaves[0:nleaves] tree, root = merkle_tree(leaves) - hex_leaf = leaf.decode('hex') + hex_leaf = unhexlify(leaf) proof = merkle_proof(hex_leaf, proof, root) return jsonify({"verified":proof}) diff --git a/ion/lithium/lithium.py b/ion/lithium/lithium.py index 2afa4e9..ac83730 100644 --- a/ion/lithium/lithium.py +++ b/ion/lithium/lithium.py @@ -9,41 +9,36 @@ """ from __future__ import print_function +import time import threading -import random -import string +from os import urandom + import click -from ethereum.utils import scan_bin, sha3, keccak +from sha3 import keccak_256 + +from ..utils import scan_bin from ..args import arg_bytes20, arg_ethrpc from ..merkle import merkle_tree, merkle_hash from .api import app -TRANSFER_SIGNATURE = keccak.new(digest_bits=256) \ - .update('IonTransfer(address,address,uint256,bytes32,bytes)') \ - .hexdigest() +TRANSFER_SIGNATURE = keccak_256(b'IonTransfer(address,address,uint256,bytes32,bytes)').hexdigest() EVENT_SIGNATURES = [TRANSFER_SIGNATURE] -def random_string(amount): - """ - Returns a random string to hash as pseudo data - """ - return ''.join(random.SystemRandom() \ - .choice(string.ascii_uppercase + string.digits) for _ in range(amount)) - - def pack_txn(txn): """ Packs all the information about a transaction into a deterministic fixed-sized array of bytes from || to """ - tx_from, tx_to, tx_value, tx_input = [scan_bin(x + ('0' * (len(x) % 2))) \ - for x in [txn['from'], txn['to'], txn['value'], txn['input']]] + fields = [txn['from'], txn['to'], txn['value'], txn['input']] + encoded_fields = [scan_bin(x + ('0' * (len(x) % 2))) for x in fields] + tx_from, tx_to, tx_value, tx_input = encoded_fields - return ''.join([ + # XXX: why is only the From and To fields... ? + return b''.join([ tx_from, tx_to ]) @@ -54,8 +49,7 @@ def pack_log(txn, log): Packs a log entry into one or more entries. sender account || token address of opposite chain from sender || ionLock address of opposite chain from sender || value || hash(reference) """ - print(scan_bin(log['topics'][2]).encode('hex')) - return ''.join([ + return b''.join([ scan_bin(txn['from']), scan_bin(txn['to']), scan_bin(log['address']), @@ -71,10 +65,8 @@ def pack_items(items): start = len(items) if start < 4: for _ in range(start, 4): - new_item = random_string(16) - items.append(sha3(new_item)) - else: - pass + new_item = urandom(16) + items.append(keccak_256(new_item).digest()) class Lithium(object): @@ -158,6 +150,10 @@ def iter_blocks(self, run_event, rpc, start=1, group=1, backlog=0, interval=1): blocks = [] is_latest = False old_head = head + try: + time.sleep(interval) + except KeyboardInterrupt: + raise StopIteration def lithium_submit(self, batch, prev_root, rpc, link, account, checkpoints, leaves): @@ -189,7 +185,7 @@ def lithium_instance(self, run_event, rpc_from, rpc_to, from_account, to_account prev_root = merkle_hash("merkle-tree-extra") print("Starting block iterator") - print("Latest Block: ", ionlock.LatestBlock) + print("Latest Block: ", ionlock.LatestBlock()) for is_latest, block_group in self.iter_blocks(run_event, rpc_from, ionlock.LatestBlock()): items, group_tx_count, group_log_count, transfers = self.process_block_group(rpc_from, block_group) diff --git a/ion/merkle.py b/ion/merkle.py index 8492acd..eeda8c1 100755 --- a/ion/merkle.py +++ b/ion/merkle.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python ## Copyright (c) 2016-2018 Clearmatics Technologies Ltd ## SPDX-License-Identifier: LGPL-3.0+ @@ -17,13 +16,15 @@ def serialize(v): """Convert to value to a hashable scalar""" if isinstance(v, str): + return v.encode('utf-8', 'backslashreplace') + if isinstance(v, bytes): return v - if isinstance(v, (int, long)): + if isinstance(v, int): return zpad(int_to_big_endian(v), 32) - raise NotImplementedError(v) + raise NotImplementedError((v, type(v))) -hashs = lambda *x: bytes_to_int(keccak_256(''.join(map(serialize, x))).digest()) +hashs = lambda *x: bytes_to_int(keccak_256(b''.join(map(serialize, x))).digest()) merkle_hash = lambda *x: bit_clear(hashs(*x), 0xFF) @@ -48,7 +49,7 @@ def merkle_tree(items): :return: list, long """ tree = [sorted(map(merkle_hash, items))] - extra = merkle_hash("merkle-tree-extra") + extra = merkle_hash(b"merkle-tree-extra") while True: level = tree[-1] # Ensure level has an even number of items, pad it with an 'extra item' @@ -119,7 +120,7 @@ def merkle_proof(leaf, path, root): def main(): # Create 99 trees of 1..N items for i in range(1, 100): - items = range(0, i) + items = list(range(0, i)) tree, root = merkle_tree(items) # Verify all items exist within the root diff --git a/ion/restclient.py b/ion/restclient.py index 7170617..9fd9f53 100644 --- a/ion/restclient.py +++ b/ion/restclient.py @@ -17,7 +17,7 @@ x = RestClient('http://example.com/') x('test').POST(abc=123) # POST /test abc=123 x.test.derp.GET() # GET /test/derp - x.test() # GET /test + x.test.GET()() # GET /test see... it's nice, and predictable, and Pythonic, and flexible, etc... """ @@ -31,20 +31,18 @@ import requests -from .utils import require - class RestClient(object): __slots__ = ('_api', '_url', '_session') def __init__(self, url, api=None): - require(url is not None, "Must provide REST API HTTP URL") + assert url is not None self._url = url self._session = None if api is None: self._session = requests.Session() self._api = self if api is None else api - require(isinstance(self._api, RestClient)) + assert isinstance(self._api, RestClient) def __getattr__(self, name): if name[0] == '_': diff --git a/ion/utils.py b/ion/utils.py index ba5cd91..250c868 100644 --- a/ion/utils.py +++ b/ion/utils.py @@ -3,9 +3,10 @@ ## SPDX-License-Identifier: LGPL-3.0+ import sys -from base64 import b64encode, b64decode -import binascii import json +from base64 import b64encode, b64decode +from binascii import hexlify, unhexlify +from functools import reduce from rlp.sedes import big_endian_int from rlp.utils import decode_hex, str_to_bytes @@ -40,7 +41,7 @@ def packl(lnum): s = hex(lnum)[2:].rstrip('L') if len(s) & 1: s = '0' + s - return binascii.unhexlify(s) + return unhexlify(s) int_to_big_endian = packl @@ -60,7 +61,7 @@ def big_endian_to_int(x): return big_endian_int.deserialize( def is_numeric(x): - return isinstance(x, (int, long)) + return isinstance(x, int) def encode_int(v): @@ -81,12 +82,13 @@ def require(arg, msg=None): if not arg: raise RuntimeError(msg or "Requirement failed") + def normalise_address(addr): if len(addr) == 20: - addr = addr.encode('hex') + addr = hexlify(addr).decode('ascii') if addr[:2] == '0x': addr = addr[2:] - require(len(addr) == 40, "Invalid address: " + addr) + require(len(addr) == 40, "Invalid address: " + str(addr)) return addr @@ -103,13 +105,13 @@ def unmarshal(cls, args): def tojson(x): - return json.dumps(marshal(x)) + return json.dumps(marshal(x), cls=CustomJSONEncoder) def marshal(x): - if isinstance(x, (int, long, type(None))): + if isinstance(x, (int, type(None))): return x - if isinstance(x, (str, bytes, unicode)): + if isinstance(x, (str, bytes)): return b64encode(x) if isinstance(x, (tuple, list)): return map(marshal, x) @@ -119,12 +121,24 @@ def marshal(x): def unmarshal(x): - if x is None or isinstance(x, (int, long)): + if x is None or isinstance(x, int): return x - if isinstance(x, (str, bytes, unicode)): + if isinstance(x, (str, bytes)): return b64decode(x) if isinstance(x, (tuple, list)): return map(unmarshal, x) if isinstance(x, Marshalled): return x.unmarshal(x) raise ValueError("Cannot unmarshal type: %r - %r" % (type(x), x)) + + +class CustomJSONEncoder(json.JSONEncoder): + def default(self, obj): + if isinstance(obj, bytes): + return obj.decode('utf-8', 'backslashreplace') + return json.JSONEncoder.default(self, obj) + + +# XXX: about about tojson? +def json_dumps(obj): + return json.dumps(obj, cls=CustomJSONEncoder) diff --git a/ion/webutils.py b/ion/webutils.py new file mode 100644 index 0000000..dd77aee --- /dev/null +++ b/ion/webutils.py @@ -0,0 +1,83 @@ +## Copyright (c) 2018 Harry Roberts. All Rights Reserved. +## SPDX-License-Identifier: LGPL-3.0+ + +from binascii import hexlify + +from flask import jsonify, abort, make_response +from werkzeug.routing import BaseConverter + +from .args import arg_bytes32, arg_bytes20, arg_uint256 +from .utils import scan_bin + + +def api_abort(message, code=400): + return abort(make_response(jsonify(dict(_error=message)), code)) + + +def param(the_dict, key): + if key not in the_dict: + return api_abort("Parameter required: " + key) + return the_dict[key] + + +def param_filter_arg(the_dict, key, filter_fn): + """ + Applies a click argument filter from `..args` to a value from a dictionary + Does the things with HTTP errors etc. upon failure. + """ + value = param(the_dict, key) + try: + value = filter_fn(None, None, value) + except Exception as ex: + return api_abort("Invalid parameter '%s' - %s" % (key, str(ex))) + return value + + +def param_bytes32(the_dict, key): + return hexlify(param_filter_arg(the_dict, key, arg_bytes32)).decode('ascii') + + +def param_bytes20(the_dict, key): + return hexlify(param_filter_arg(the_dict, key, arg_bytes20)).decode('ascii') + + +def param_uint256(the_dict, key): + return param_filter_arg(the_dict, key, arg_uint256) + + +class BytesConverter(BaseConverter): + """ + Accepts hex encoded bytes as an argument + Provides raw bytes to Python + Marshals between raw bytes and hex encoded + """ + BYTES_LEN = None + def __init__(self, url_map, *items): + assert self.BYTES_LEN is not None + super(BytesConverter, self).__init__(url_map) + self.regex = '(0x)?[a-fA-F0-9]{' + str(self.BYTES_LEN * 2) + '}' + + def to_python(self, value): + # Normalise hex encoding... + return hexlify(scan_bin(value)).decode('ascii') + + +class Bytes32Converter(BytesConverter): + """Accept 32 hex-encoded bytes as URL param""" + BYTES_LEN = 32 + + +class Bytes20Converter(BytesConverter): + """Accept 20 hex-encoded bytes as URL param""" + BYTES_LEN = 20 + + +def params_parse(data, params): + """ + Performs param unmarshalling operations to return a dictionary + params_parse({...}, {name: unmarshal}) + """ + out = dict() + for key, val in params.items(): + out[key] = val(data, key) + return out diff --git a/setup.py b/setup.py index faa4639..e1ac66e 100644 --- a/setup.py +++ b/setup.py @@ -8,6 +8,7 @@ version='0.1', packages=['ion'], py_modules=['__main__'], + python_requires='>3.5.0', install_requires=[str(ir.req) for ir in parse_requirements('requirements.txt', session=PipSession())], entry_points=''' [console_scripts] diff --git a/test/test_api.py b/test/test_api.py index 6a40d66..f762cff 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -35,20 +35,25 @@ EXPECTED_BLOCKID = \ { - u'blockid': u'6ce75c011eac6f587c54493784ce2139b70e38b5b04fedab2bf5a84b500d0d92' + u'blockid': u'81d9d8277b8f741b859de5455b9b56ff240d2ecf19101df3da9b76b137e5a7e6' } PROOF = \ { u'proof': [ - u'54014011439648363204354998496393219114331058923926644452979013882180058964973', - u'111156487848132035204691335325227635200969078435864690888530225168808220587159', - u'112042151246272191572954036892630855408766946838390755837084327398547991526295' + u'97923772266235395715382770652917280357200452858347804453224054177914019790625', + u'98712613182025294006941209131503858290191202453957227771980554773023716000070' ] } class MockLithium(): - leaves = ['\xff\xcf\x8f\xde\xe7*\xc1\x1b\\T$(\xb3^\xefWi\xc4\t\xf0\xc8\x9c\xe4sX\x82\xc9\xf0\xf0\xfe&hlS\x07N\t\xb0\xd5P\xd83!\\\xbc\xc3\xf9\x14\xbd\x1c\x9e\xce>\xe7\xbf\x8b\x14\xf8A\xbb\x03\xe8\xe6\xa7v_F\xf7!\xf4\xbe\xe7\xbf\x8b\x14\xf8A\xbb\x03\xe8\xe6\xa7v_F\xf7!\xf4\xbe\xe7\xbf\x8b\x14\xf8A\xbb\x03\xe8\xe6\xa7v_F\xf7!\xf4\xbe\xe7\xbf\x8b\x14\xf8A\xbb\x03\xe8\xe6\xa7v_F\xf7!\xf4\xbe