├── .circleci └── config.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── docker-compose.yml ├── factom ├── __init__.py ├── client.py ├── exceptions.py ├── livefeed │ ├── __init__.py │ └── listener.py ├── session.py └── utils.py ├── requirements.txt ├── screenshots └── chain.png ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── integration │ ├── __init__.py │ └── test_api.py ├── test_client.py ├── test_exceptions.py └── test_session.py └── tox.ini /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | jobs: 3 | build: 4 | machine: 5 | image: ubuntu-1604:201903-01 6 | steps: 7 | - checkout 8 | - restore_cache: 9 | keys: 10 | - v2-dependencies-{{ checksum "requirements.txt" }} 11 | - run: 12 | name: Install dependencies 13 | command: | 14 | pyenv local 3.7.0 15 | python3 -m venv venv 16 | . venv/bin/activate 17 | pip install -U pip 18 | pip install -r requirements.txt 19 | - save_cache: 20 | paths: 21 | - ./venv 22 | key: v2-dependencies-{{ checksum "requirements.txt" }} 23 | - run: 24 | name: Lint code 25 | command: | 26 | . venv/bin/activate 27 | flake8 28 | - run: 29 | name: Install python test versions 30 | command: | 31 | git clone git://github.com/pyenv/pyenv-update.git $(pyenv root)/plugins/pyenv-update 32 | pyenv update 33 | pyenv install 3.8.5 34 | pyenv local 3.5.2 3.6.5 3.7.0 3.8.5 35 | - run: 36 | name: Run tests 37 | command: | 38 | . venv/bin/activate 39 | tox 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | 103 | .DS_Store 104 | .idea/ 105 | .vscode/ 106 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM bhomnick/python-multi 2 | ENV PYTHONUNBUFFERED 1 3 | RUN apt-get update -yy && apt-get install -q -y pandoc 4 | RUN mkdir /src 5 | WORKDIR /src 6 | COPY requirements.txt /src/ 7 | RUN bash -lc "pip3.6 install -r requirements.txt" 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Ben Homnick 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE 3 | include screenshots/* 4 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL=/bin/bash 2 | 3 | # Docker-compose 4 | build: ## Build all docker images. 5 | docker-compose build 6 | flake8: ## Run flake8. 7 | docker-compose run factom-api bash -lc "flake8 ." 8 | shell: ## Open a bash shell inside docker conatiner. 9 | docker-compose run factom-api bash 10 | sandbox: ## Run the factom sandbox server. 11 | docker-compose up factom-sandbox 12 | test: ## Run test suite with latest Python version. 13 | docker-compose run factom-api bash -lc "python3.6 -m pytest" 14 | tox: ## Run tox. 15 | docker-compose run factom-api bash -lc "tox" 16 | clean: ## Clean the application. 17 | find . -name '*.py[co]' -delete 18 | find . -type d -name "__pycache__" -delete 19 | help: ## Show this help. 20 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # factom-api 2 | 3 | ![CircleCI](https://img.shields.io/circleci/build/github/FactomProject/factom-api) 4 | ![PyPI](https://img.shields.io/pypi/v/factom-api) 5 | 6 | This library provides Python clients for interacting with the factomd and factom-walletd APIs. While not all API methods have been implemented yet, you'll find most of what you need to build a working application are available, along with shortcut methods for accomplishing common tasks involving multiple calls between the wallet and daemon. 7 | 8 | This API client (from version 1.2 onwards) supports Python 3.5 and higher. 9 | 10 | If you're unfamiliar with Factom, I encourage you to [read the documentation](http://docs.factom.com/), especially the [white paper](https://github.com/FactomProject/FactomDocs/blob/master/whitepaper.md). In a nutshell, Factom provides a layer on top of the Bitcoin blockchain making it possible to secure data faster and in larger amounts than the Bitcoin network would allow alone. 11 | 12 | ## Getting started 13 | 14 | ### Installing the API client 15 | 16 | The easiest way to install is directly from pip: 17 | 18 | ``` 19 | $ pip install factom-api 20 | ``` 21 | 22 | ## Usage 23 | 24 | I'll go over a few common operations here you'll most likely use in applications. In general you'll need instances of both the wallet and factomd clients to build and submit transactions. To build new clients: 25 | 26 | ```python 27 | from factom import Factomd, FactomWalletd 28 | 29 | # Default settings 30 | factomd = Factomd() 31 | walletd = FactomWalletd() 32 | 33 | # You can also specify default fct and ec addresses, change host, or specify RPC credentials, for example: 34 | fct_address = 'FA2jK2HcLnRdS94dEcU27rF3meoJfpUcZPSinpb7AwQvPRY6RL1Q' 35 | ec_address = 'EC2jhmCtabeTXGtuLi3AaPzvwSuqksdVsjfxXMXV5gPmipXc4GjC' 36 | 37 | factomd = Factomd( 38 | host='http://someotherhost:8088', 39 | fct_address=fct_address, 40 | ec_address=ec_address, 41 | username='rpc_username', 42 | password='rpc_password' 43 | ) 44 | ``` 45 | 46 | ### Transacting factoids to factoids 47 | 48 | First let's query the balance in both of our fct addresses: 49 | 50 | ```python 51 | >>> fct_address1 = 'FA2jK2HcLnRdS94dEcU27rF3meoJfpUcZPSinpb7AwQvPRY6RL1Q' 52 | >>> fct_address2 = 'FA3TMQHrCrmLa4F9t442U3Ab3R9sM1gThYMDoygPEVtxrbHtFRtg' 53 | >>> ec_address = 'EC2jhmCtabeTXGtuLi3AaPzvwSuqksdVsjfxXMXV5gPmipXc4GjC' 54 | 55 | # Initialize the two clients 56 | >>> factomd = Factomd() 57 | >>> walletd = FactomWalletd() 58 | 59 | # Query the balance in our first address. There should be a large amount 60 | >>> factomd.factoid_balance(fct_address1) 61 | {'balance': 1999999735950} 62 | 63 | # The second address should be empty. 64 | >>> factomd.factoid_balance(fct_address2) 65 | {'balance': 0} 66 | ``` 67 | 68 | The wallet client provides a shorcut method `fct_to_fct()` which performs all the API calls needed to submit a simple fct to fct transaction. This includes adding inputs and outputs, calculating the fee, building the signed transaction, and submitting it to the network. 69 | 70 | ```python 71 | >>> walletd.fct_to_fct(factomd, 50000, fct_to=fct_address2, fct_from=fct_address1) 72 | {'message': 'Successfully submitted the transaction', 'txid': 'a4d641f13d82b1d1682549d44fa41c7e1b01f1a16f8cbddb5c695df53fcebfd7'} 73 | ``` 74 | 75 | The server reports the transaction was submitted and if we wait a few seconds we can see the results: 76 | 77 | ```python 78 | >>> factomd.factoid_balance(fct_address2) 79 | {'balance': 50000} 80 | ``` 81 | 82 | ### Converting factoids to entry credits 83 | 84 | Our new entry credit address should have a balance of zero: 85 | 86 | ```python 87 | >>> factomd.entry_credit_balance(ec_address) 88 | {'balance': 0} 89 | ``` 90 | 91 | First, we need to ask for the conversion rate: 92 | 93 | ```python 94 | >>> factomd.entry_credit_rate() 95 | {'rate': 1000} 96 | ``` 97 | 98 | This tells us we'll need to burn 1000 factoids in exchange for 1 entry credit, so let's purchase 50 entry credits for 50000 factoids. Similar to `fct_to_fct()`, the wallet client also provides a `fct_to_ec()` shortcut for building and submitting simple fct conversion transactions. 99 | 100 | ```python 101 | >>> walletd.fct_to_ec(factomd, 50000, fct_address=fct_address1, ec_address=ec_address) 102 | {'message': 'Successfully submitted the transaction', 'txid': 'd70b14ce05a21dbf772d1894383694b4537e17454915fc42dc20f02c1e0e2df2'} 103 | ``` 104 | 105 | And if we query our entry credit balance we see the conversion has happened: 106 | 107 | ```python 108 | >>> factomd.entry_credit_balance(ec_address) 109 | {'balance': 50} 110 | ``` 111 | 112 | ### Writing chains and entries 113 | 114 | The real meat and potatoes is the ability to easily read from and write data to the blockchain. Let's write some test data. The wallet client provides a `new_chain()` shortcut method that handles the API calls and encoding needed for creating a new chain. You could also build the transaction manually if you'd like more control over each step, but for most cases this is going to be easier. 115 | 116 | ```python 117 | >>> walletd.new_chain(factomd, ['random', 'chain', 'id'], 'chain_content', ec_address=ec_address) 118 | {'message': 'Entry Reveal Success', 'entryhash': 'f9662a4675f4bb6566337eafd8237ab9fd2ba396947dadeb677c0526d367a5ce', 'chainid': 'da2ffed0ae7b33acc718089edc0f1d001289857cc27a49b6bc4dd22fac971495'} 119 | ``` 120 | 121 | If we wait a few minutes and search for the chain ID in the explorer we can see our initial entry: 122 | 123 | ![Our new chain](screenshots/chain.png "Our new chain") 124 | 125 | Now let's add another entry to the same chain: 126 | 127 | ```python 128 | >>> chain_id = 'da2ffed0ae7b33acc718089edc0f1d001289857cc27a49b6bc4dd22fac971495' 129 | >>> walletd.new_entry(factomd, chain_id, ['random', 'entry', 'id'], 'entry_content', ec_address=ec_address) 130 | {'message': 'Entry Reveal Success', 'entryhash': '96f0472c9ec8a76c861fb4df37beb742938f41bbe492dc04893337bf387b83c5', 'chainid': 'da2ffed0ae7b33acc718089edc0f1d001289857cc27a49b6bc4dd22fac971495'} 131 | ``` 132 | 133 | You should see the new entry appear shortly. 134 | 135 | ### Reading entries 136 | 137 | If the entries in your chain reference each other, you may want to scan the entire chain in order to verify its integrity. The factomd client provides a `read_chain()` method which iterates over all entry-containing blocks and returns a list of entries in reverse order. 138 | 139 | ```python 140 | >>> chain_id = 'da2ffed0ae7b33acc718089edc0f1d001289857cc27a49b6bc4dd22fac971495' 141 | >>> factomd.read_chain(chain_id) 142 | [{'chainid': 'da2ffed0ae7b33acc718089edc0f1d001289857cc27a49b6bc4dd22fac971495', 'extids': ['random', 'entry', 'id'], 'content': 'entry_content'}, {'chainid': 'da2ffed0ae7b33acc718089edc0f1d001289857cc27a49b6bc4dd22fac971495', 'extids': ['random', 'chain', 'id'], 'content': 'chain_content'}] 143 | ``` 144 | 145 | You can see the two entries we created earlier. 146 | 147 | ### Error handling 148 | 149 | When things go badly, API methods will raise a `factom.exceptions.FactomAPIError` with details about the error. 150 | 151 | ```python 152 | >>> walletd.new_chain(factomd, ['random', 'chain', 'id'], 'chain_content', ec_address=ec_address) 153 | Traceback (most recent call last): 154 | File "", line 1, in 155 | File "/src/factom/client.py", line 196, in new_chain 156 | 'ecpub': ec_address or self.ec_address 157 | File "/src/factom/client.py", line 56, in _request 158 | handle_error_response(resp) 159 | File "/src/factom/exceptions.py", line 18, in handle_error_response 160 | raise codes[code](message=message, code=code, data=data, response=resp) 161 | factom.exceptions.InvalidParams: -32602: Invalid params 162 | ``` 163 | 164 | More data about the error is attached to the exception instance: 165 | 166 | ```python 167 | >>> try: 168 | ... walletd.new_chain(factomd, ['random', 'chain', 'id'], 'chain_content', ec_address=ec_address) 169 | ... except FactomAPIError as e: 170 | ... print(e.data) 171 | ... 172 | Chain da2ffed0ae7b33acc718089edc0f1d001289857cc27a49b6bc4dd22fac971495 already exists 173 | ``` 174 | 175 | If you'd like to catch more specific errors, there are exception subclasses for the different error codes returned by the APIs. See [factom/exceptions.py](factom/exceptions.py) for a list. 176 | 177 | ### Listening to LiveFeed 178 | 179 | The LiveFeed in factomd pipes out data corresponding to various network events and pieces of work that were accomplished, such as processing an entry or sealing a directory block. The `factom.livefeed.LiveFeedShovel` class listens to the LiveFeed and handles that data in some way. As an example, if you had JSON output turned on for the LiveFeed: 180 | 181 | ```python 182 | import logging 183 | import time 184 | from factom.livefeed.listener import LiveFeedListener 185 | 186 | 187 | def log_event(event_payload: bytes): 188 | logging.info(f"Event: {event_payload.decode()}") 189 | 190 | 191 | listener = LiveFeedListener(handle=log_event) 192 | 193 | while True: 194 | try: 195 | listener.run() 196 | except ConnectionResetError: 197 | logging.warning("Connection reset by peer. Sleeping 5 seconds and restarting...") 198 | time.sleep(5) 199 | except KeyboardInterrupt: 200 | logging.info("KeyboardInterrupt received, shutting down...") 201 | break 202 | ``` 203 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | factom-api: 5 | build: . 6 | volumes: 7 | - .:/src 8 | extra_hosts: 9 | - localhost:172.17.0.1 10 | factom-sandbox: 11 | image: bhomnick/factom 12 | ports: 13 | - "8088:8088" 14 | - "8089:8089" 15 | - "8090:8090" 16 | volumes: 17 | - factomdata:/root/.factom/ 18 | extra_hosts: 19 | - localhost:172.17.0.1 20 | 21 | volumes: 22 | factomdata: 23 | -------------------------------------------------------------------------------- /factom/__init__.py: -------------------------------------------------------------------------------- 1 | from .client import Factomd, FactomWalletd # noqa 2 | -------------------------------------------------------------------------------- /factom/client.py: -------------------------------------------------------------------------------- 1 | import random 2 | import string 3 | import time 4 | from typing import List, Union 5 | from urllib.parse import urljoin 6 | 7 | import factom.utils as utils 8 | 9 | from .exceptions import handle_error_response 10 | from .session import FactomAPISession 11 | 12 | 13 | NULL_BLOCK = "0000000000000000000000000000000000000000000000000000000000000000" 14 | 15 | 16 | class BaseAPI(object): 17 | def __init__( 18 | self, 19 | ec_address=None, 20 | fct_address=None, 21 | host=None, 22 | version="v2", 23 | username=None, 24 | password=None, 25 | certfile=None 26 | ): 27 | """ 28 | Instantiate a new API client. 29 | 30 | Args: 31 | ec_address (str): A default entry credit address to use for 32 | transactions. Credits will be spent from this address 33 | with the exception of the `fct_to_ec()` shortcut. 34 | fct_address (str): A default factoid address to use for 35 | transactions. Factoids will be spent from this address. 36 | host (str): Hostname, including http(s)://, of the factomd 37 | or factom-walletd instance to query. 38 | version (str): API version to use. This should remain 'v2'. 39 | username (str): RPC username for protected APIs. 40 | password (str): RPC password for protected APIs. 41 | certfile (str): Path to certificate file to verify for TLS 42 | connections (mostly untested). 43 | """ 44 | self.ec_address = ec_address 45 | self.fct_address = fct_address 46 | self.version = version 47 | 48 | if host: 49 | self.host = host 50 | 51 | self.session = FactomAPISession() 52 | 53 | if username and password: 54 | self.session.init_basic_auth(username, password) 55 | 56 | if certfile: 57 | self.session.init_tls(certfile) 58 | 59 | @property 60 | def url(self): 61 | return urljoin(self.host, self.version) 62 | 63 | @staticmethod 64 | def _xact_name(): 65 | return "TX_{}".format("".join(random.choices(string.ascii_uppercase + string.digits, k=6))) 66 | 67 | def _request(self, method, params=None, request_id: int = 0): 68 | data = {"jsonrpc": "2.0", "id": request_id, "method": method} 69 | if params: 70 | data["params"] = params 71 | 72 | resp = self.session.request("POST", self.url, json=data) 73 | 74 | if resp.status_code >= 400: 75 | handle_error_response(resp) 76 | 77 | return resp.json()["result"] 78 | 79 | 80 | class Factomd(BaseAPI): 81 | host = "http://localhost:8088" 82 | 83 | def admin_block(self, keymr: Union[bytes, str]): 84 | """ 85 | Retrieve a specified admin block given its key Merkle root. 86 | """ 87 | return self._request("admin-block", {"keymr": utils.hex_from_bytes_or_string(keymr)}) 88 | 89 | def admin_block_by_height(self, height: int): 90 | """ 91 | Retrieves administrative blocks for any given height. The admin block 92 | contains data related to the identities within the Factom system and the 93 | decisions the system makes as it builds the blockchain. The abentries 94 | (admin block entries) in the JSON response can be of various types, the 95 | most common is a directory block signature (DBSig). 96 | """ 97 | return self._request("ablock-by-height", {"height": height}) 98 | 99 | def anchors(self, object_hash: Union[bytes, str] = None, height: int = None): 100 | """ 101 | Retrieve the set of anchors for a given object hash or directory block 102 | height. 103 | """ 104 | if object_hash is None: 105 | assert height is not None, "No object_hash provided, height must not be none" 106 | assert height >= 0, "Height must be >= 0" 107 | params = {"height": height} 108 | else: 109 | assert height is None, "Hash provided, height must be None" 110 | params = {"hash": utils.hex_from_bytes_or_string(object_hash)} 111 | 112 | return self._request("anchors", params) 113 | 114 | def chain_head(self, chain_id: Union[bytes, str]): 115 | return self._request("chain-head", {"chainid": utils.hex_from_bytes_or_string(chain_id)}) 116 | 117 | def commit_chain(self, message: Union[bytes, str]): 118 | return self._request("commit-chain", {"message": utils.hex_from_bytes_or_string(message)}) 119 | 120 | def commit_entry(self, message: Union[bytes, str]): 121 | return self._request("commit-entry", {"message": utils.hex_from_bytes_or_string(message)}) 122 | 123 | def current_minute(self): 124 | """ 125 | The current-minute API call returns: 126 | 127 | leaderheight: The current block height. 128 | directoryblockheight: The last saved height. 129 | minute: The current minute number for the open entry block. 130 | currentblockstarttime: The start time for the current block. 131 | currentminutestarttime: The start time for the current minute. 132 | currenttime: The current nodes understanding of current time. 133 | directoryblockinseconds: The number of seconds per block. 134 | stalldetected: If factomd thinks it has stalled. 135 | faulttimeout: The number of seconds before leader node is faulted for 136 | failing to provide a necessary message. 137 | roundtimeout: The number of seconds between rounds of an election during 138 | a fault. 139 | """ 140 | return self._request("current-minute") 141 | 142 | def directory_block_by_height(self, height: int): 143 | """ 144 | Retrieve a directory block given only its height. 145 | """ 146 | return self._request("dblock-by-height", {"height": height}) 147 | 148 | def directory_block_by_keymr(self, keymr: Union[bytes, str]): 149 | """ 150 | Every directory block has a KeyMR (Key Merkle Root), which can be used 151 | to retrieve it. The response will contain information that can be used 152 | to navigate through all transactions (entry and factoid) within that 153 | block. The header of the directory block will contain information 154 | regarding the previous directory block key Merkle root, directory block 155 | height, and the timestamp. 156 | """ 157 | return self._request("directory-block", {"keymr": utils.hex_from_bytes_or_string(keymr)}) 158 | 159 | def directory_block_head(self): 160 | """ 161 | The directory block head is the last known directory block by factom, or 162 | in other words, the most recently recorded block. This can be used to 163 | grab the latest block and the information required to traverse the 164 | entire blockchain. 165 | """ 166 | return self._request("directory-block-head") 167 | 168 | def entry(self, entry_hash: Union[bytes, str], encode_as_hex: bool = False): 169 | """ 170 | Get an Entry from factomd specified by the Entry Hash. If 171 | `encode_as_hex` is True, content and external ids will be returned as 172 | hex strings rather than bytes-objects. 173 | """ 174 | resp = self._request("entry", {"hash": utils.hex_from_bytes_or_string(entry_hash)}) 175 | if not encode_as_hex: 176 | resp["extids"] = [bytes.fromhex(x) for x in resp["extids"]] 177 | resp["content"] = bytes.fromhex(resp["content"]) 178 | return resp 179 | 180 | def entry_block(self, keymr: Union[bytes, str]): 181 | """ 182 | Retrieve a specified entry block given its Merkle root key. The entry 183 | block contains 0 to many entries. 184 | """ 185 | return self._request("entry-block", {"keymr": utils.hex_from_bytes_or_string(keymr)}) 186 | 187 | def entry_credit_balance(self, ec_address=None): 188 | """ 189 | Return its current balance for a specific entry credit address. 190 | """ 191 | return self._request("entry-credit-balance", {"address": ec_address or self.ec_address}) 192 | 193 | def entry_credit_block(self, keymr: Union[bytes, str]): 194 | """ 195 | Retrieve a specified entry credit block (including minute markers) given 196 | its key Merkle root. 197 | """ 198 | return self._request("entrycredit-block", {"keymr": utils.hex_from_bytes_or_string(keymr)}) 199 | 200 | def entry_credit_block_by_height(self, height: int): 201 | """ 202 | Retrieve the entry credit block for any given height. These blocks 203 | contain entry credit transaction information. 204 | """ 205 | return self._request("ecblock-by-height", {"height": height}) 206 | 207 | def entry_credit_rate(self): 208 | """ 209 | Returns the number of Factoshis (Factoids *10^-8) that purchase a single 210 | Entry Credit. The minimum factoid fees are also determined by this rate, 211 | along with how complex the factoid transaction is. 212 | """ 213 | return self._request("entry-credit-rate") 214 | 215 | def factoid_balance(self, fct_address=None): 216 | """ 217 | Returns the number of Factoshis (Factoids *10^-8) that are currently 218 | available at the address specified. 219 | """ 220 | return self._request("factoid-balance", {"address": fct_address or self.fct_address}) 221 | 222 | def factoid_block_by_height(self, height: int): 223 | """ 224 | Retrieve the factoid block for any given height. These blocks contain 225 | factoid transaction information. 226 | """ 227 | return self._request("fblock-by-height", {"height": height}) 228 | 229 | def factoid_block_by_keymr(self, keymr: Union[bytes, str]): 230 | """ 231 | Retrieve a specified factoid block given its key Merkle root. 232 | """ 233 | return self._request("factoid-block", {"keymr": utils.hex_from_bytes_or_string(keymr)}) 234 | 235 | def factoid_submit(self, transaction: Union[bytes, str]): 236 | """ 237 | The factoid-submit API takes a specifically formatted message as bytes 238 | or a hex string that includes signatures. If you have a factom-walletd 239 | instance running, you can construct this factoid-submit API call with 240 | compose-transaction which takes easier to construct arguments. 241 | """ 242 | return self._request("factoid-submit", { 243 | "transaction": utils.hex_from_bytes_or_string(transaction)}) 244 | 245 | def heights(self): 246 | """ 247 | Returns various heights that allows you to view the state of the 248 | blockchain. The heights returned provide a lot of information 249 | regarding the state of factomd, but not all are needed by most 250 | applications. The heights also indicate the most recent block, 251 | which could not be complete, and still being built. The heights 252 | mean as follows: 253 | 254 | directoryblockheight: The current directory block height of the local 255 | factomd node. 256 | leaderheight: The current block being worked on by the leaders in the 257 | network. This block is not yet complete, but all transactions 258 | submitted will go into this block (depending on network conditions, 259 | the transaction may be delayed into the next block). 260 | entryblockheight: The height at which the factomd node has all the 261 | entry blocks. Directory blocks are obtained first, entry blocks 262 | could be lagging behind the directory block when syncing. 263 | entryheight: The height at which the local factomd node has all the 264 | entries. If you added entries at a block height above this, they 265 | will not be able to be retrieved by the local factomd until it syncs 266 | further. 267 | """ 268 | return self._request("heights") 269 | 270 | def multiple_entry_credit_balances(self, ec_address_list: List[str]): 271 | """ 272 | Used to query the acknowledged and saved balances for a list of entry 273 | credit addresses. 274 | 275 | Args: 276 | ec_address_list (list[str]): A list of entry credit addresses. 277 | """ 278 | return self._request("multiple-ec-balances", {"addresses": ec_address_list}) 279 | 280 | def multiple_factoid_balances(self, fct_address_list: List[str]): 281 | """ 282 | Used to query the acknowledged and saved balances in factoshis (a 283 | factoshi is 10^8 factoids) not factoids(FCT) for a list of FCT 284 | addresses. 285 | 286 | Args: 287 | fct_address_list (list[str]): A list of factoid addresses. 288 | """ 289 | return self._request("multiple-fct-balances", {"addresses": fct_address_list}) 290 | 291 | def pending_entries(self): 292 | """ 293 | Returns an array of the entries that have been submitted but have not 294 | been recorded into the blockchain. 295 | """ 296 | return self._request("pending-entries") 297 | 298 | def pending_transactions(self): 299 | """ 300 | Returns an array of factoid transactions that have not yet been recorded 301 | in the blockchain, but are known to the system. 302 | """ 303 | return self._request("pending-transactions") 304 | 305 | def properties(self): 306 | """ 307 | Retrieve current properties of the Factom system, including the software 308 | and the API versions. 309 | """ 310 | return self._request("properties") 311 | 312 | def raw_data(self, object_hash: Union[bytes, str]): 313 | """ 314 | Retrieve an entry, transaction, or block in raw (marshalled) format. 315 | """ 316 | return self._request("raw-data", {"hash": utils.hex_from_bytes_or_string(object_hash)}) 317 | 318 | def receipt(self, entry_hash: Union[bytes, str], include_raw_entry: bool = False): 319 | """ 320 | Retrieve a receipt providing cryptographically verifiable proof that 321 | information was recorded in the Factom blockchain and that this was 322 | subsequently anchored in the bitcoin blockchain. 323 | """ 324 | return self._request("receipt", { 325 | "hash": utils.hex_from_bytes_or_string(entry_hash), 326 | "includerawentry": include_raw_entry 327 | }) 328 | 329 | def reveal_chain(self, entry: Union[bytes, str]): 330 | return self._request("reveal-chain", {"entry": utils.hex_from_bytes_or_string(entry)}) 331 | 332 | def reveal_entry(self, entry: Union[bytes, str]): 333 | return self._request("reveal-entry", {"entry": utils.hex_from_bytes_or_string(entry)}) 334 | 335 | def send_raw_message(self, message: Union[bytes, str]): 336 | """ 337 | Send a raw hex encoded binary message to the Factom network. This is 338 | mostly just for debugging and testing. 339 | """ 340 | return self._request("send-raw-message", { 341 | "message": utils.hex_from_bytes_or_string(message) 342 | }) 343 | 344 | def transaction(self, tx_hash: Union[bytes, str]): 345 | """ 346 | Retrieve details of a factoid transaction using a transaction hash (or 347 | corresponding transaction id). 348 | """ 349 | return self._request("transaction", {"hash": utils.hex_from_bytes_or_string(tx_hash)}) 350 | 351 | # Convenience methods 352 | 353 | def entries_in_entry_block( 354 | self, 355 | block: dict, 356 | include_entry_context: bool = False, 357 | encode_as_hex: bool = False 358 | ): 359 | """ 360 | A generator that yields all entries within a given entry block. 361 | """ 362 | for entry_pointer in block["entrylist"]: 363 | entry = self.entry(entry_pointer["entryhash"], encode_as_hex=encode_as_hex) 364 | if include_entry_context: 365 | entry["entryhash"] = entry_pointer["entryhash"] 366 | entry["timestamp"] = entry_pointer["timestamp"] 367 | entry["dbheight"] = block["header"]["dbheight"] 368 | yield entry 369 | 370 | def read_chain( 371 | self, 372 | chain_id: Union[bytes, str], 373 | from_height: int = 0, 374 | include_entry_context: bool = False, 375 | encode_as_hex: bool = False, 376 | ): 377 | """ 378 | A generator that yields all entries of a chain in order, optionally 379 | starting from a given block height. 380 | """ 381 | # Walk the entry block chain backwards to build up a stack of entry 382 | # blocks to fetch 383 | entry_blocks = [] 384 | keymr = self.chain_head(chain_id)["chainhead"] 385 | while keymr != NULL_BLOCK: 386 | block = self.entry_block(keymr) 387 | if block["header"]["dbheight"] < from_height: 388 | break 389 | entry_blocks.append(block) 390 | keymr = block["header"]["prevkeymr"] 391 | 392 | # Continuously pop off the stack and yield each entry one by one (in the 393 | # order that they appear in the block) 394 | while len(entry_blocks) > 0: 395 | entry_block = entry_blocks.pop() 396 | yield from self.entries_in_entry_block(entry_block, include_entry_context, 397 | encode_as_hex) 398 | 399 | def entries_at_height( 400 | self, 401 | chain_id: Union[bytes, str], 402 | height: int, 403 | include_entry_context: bool = False, 404 | encode_as_hex: bool = False 405 | ): 406 | """ 407 | A generator that yields all entries in a chain that occurred at the 408 | given height. 409 | """ 410 | # Look for the chain id in the directory block entries 411 | target_chain_id = utils.hex_from_bytes_or_string(chain_id) 412 | directory_block = self.directory_block_by_height(height)["dblock"] 413 | for entry_block_pointer in directory_block["dbentries"]: 414 | if entry_block_pointer["chainid"] == target_chain_id: 415 | entry_block_keymr = entry_block_pointer["keymr"] 416 | break 417 | else: 418 | return [] # Early return, chain didn't have entries in this block 419 | 420 | # Entry block found, yield all entries within the block 421 | entry_block = self.entry_block(entry_block_keymr) 422 | yield from self.entries_in_entry_block(entry_block, include_entry_context, 423 | encode_as_hex) 424 | 425 | 426 | class FactomWalletd(BaseAPI): 427 | host = "http://localhost:8089" 428 | 429 | def add_ec_output(self, name: str, amount: int, ec_address: str = None): 430 | return self._request("add-ec-output", { 431 | "tx-name": name, 432 | "amount": amount, 433 | "address": ec_address or self.ec_address 434 | }) 435 | 436 | def add_fee(self, name: str, fct_address: str = None): 437 | return self._request("add-fee", { 438 | "tx-name": name, 439 | "address": fct_address or self.fct_address 440 | }) 441 | 442 | def add_input(self, name: str, amount: int, fct_address: str = None): 443 | return self._request("add-input", { 444 | "tx-name": name, 445 | "amount": amount, 446 | "address": fct_address or self.fct_address 447 | }) 448 | 449 | def add_output(self, name: str, amount: int, fct_address: str): 450 | return self._request("add-output", { 451 | "tx-name": name, 452 | "amount": amount, 453 | "address": fct_address 454 | }) 455 | 456 | def address(self, address: str): 457 | """ 458 | Retrieve the public and private parts of a Factoid or Entry Credit 459 | address stored in the wallet. 460 | """ 461 | return self._request("address", {"address": address}) 462 | 463 | def all_addresses(self): 464 | """ 465 | Retrieve all of the Factoid and Entry Credit addresses stored in the 466 | wallet. 467 | """ 468 | return self._request("all-addresses") 469 | 470 | def compose_transaction(self, name: str): 471 | return self._request("compose-transaction", {"tx-name": name}) 472 | 473 | def delete_transaction(self, name: str): 474 | """ 475 | Deletes a working transaction in the wallet. The full transaction will 476 | be returned, and then deleted. 477 | """ 478 | return self._request("delete-transaction", {"tx-name": name}) 479 | 480 | def generate_entry_credit_address(self): 481 | """ 482 | Create a new Entry Credit Address and store it in the wallet. 483 | """ 484 | return self._request("generate-ec-address") 485 | 486 | def generate_factoid_address(self): 487 | """ 488 | Create a new Factoid Address and store it in the wallet. 489 | """ 490 | return self._request("generate-factoid-address") 491 | 492 | def get_height(self): 493 | """ 494 | Get the current hight of blocks that have been cached by the wallet 495 | while syncing. 496 | """ 497 | return self._request("get-height") 498 | 499 | def import_address(self, secret_key: str): 500 | """ 501 | Import a single Factoid and/or Entry Credit address secret key into the 502 | wallet. 503 | """ 504 | return self._request("import-addresses", {"addresses": [{"secret": secret_key}]}) 505 | 506 | def import_addresses(self, secret_keys: List[str]): 507 | """ 508 | Import a list of Factoid and/or Entry Credit address secret keys into 509 | the wallet. 510 | """ 511 | return self._request("import-addresses", { 512 | "addresses": [{"secret": k} for k in secret_keys] 513 | }) 514 | 515 | def import_koinify(self, words: str): 516 | """ 517 | Import a Koinify crowd sale address into the wallet. 518 | """ 519 | return self._request("import-koinify", {"words": words}) 520 | 521 | def new_transaction(self, name: str = None): 522 | return self._request("new-transaction", {"tx-name": name or self._xact_name()}) 523 | 524 | def properties(self): 525 | """ 526 | Retrieve current properties of factom-walletd, including the wallet and 527 | wallet API versions. 528 | """ 529 | return self._request("properties") 530 | 531 | def sign_transaction(self, name: str, force: bool = False): 532 | return self._request("sign-transaction", {"tx-name": name, "force": force}) 533 | 534 | def sub_fee(self, name: str, fct_address: str): 535 | return self._request("sub-fee", {"tx-name": name, "address": fct_address}) 536 | 537 | def temporary_transactions(self): 538 | """ 539 | Lists all the current working transactions in the wallet. These are 540 | transactions that are not yet sent. 541 | """ 542 | return self._request("tmp-transactions") 543 | 544 | def transactions_by_range(self, start_block: int, end_block: int): 545 | """ 546 | This will retrieve all transactions within a given block height range. 547 | """ 548 | return self._request("transactions", {"start": start_block, "end": end_block}) 549 | 550 | def transactions_by_txid(self, tx_id: Union[bytes, str]): 551 | """ 552 | This will retrieve a transaction by the given TxID. This call is the 553 | fastest way to retrieve a transaction, but it will not display the 554 | height of the transaction. If a height is in the response, it will be 0. 555 | To retrieve the height of a transaction, use the By Address method. 556 | """ 557 | return self._request("transactions", {"txid": utils.hex_from_bytes_or_string(tx_id)}) 558 | 559 | def transactions_by_address(self, fct_address: str): 560 | """ 561 | Retrieves all transactions that involve a particular address. 562 | """ 563 | return self._request("transactions", {"address": fct_address}) 564 | 565 | def wallet_backup(self): 566 | """ 567 | Return the wallet seed and all addresses in the wallet for backup and 568 | offline storage. 569 | """ 570 | return self._request("wallet-backup") 571 | 572 | def wallet_balances(self): 573 | """ 574 | The wallet-balances API is used to query the acknowledged and saved 575 | balances for all addresses in the currently running factom-walletd. The 576 | saved balance is the last saved to the database and the acknowledged or 577 | ack balance is the balance after processing any in-flight transactions 578 | known to the Factom node responding to the API call. The factoid address 579 | balance will be returned in factoshis (a factoshi is 10^8 factoids) not 580 | factoids (FCT) and the entry credit balance will be returned in entry 581 | credits. 582 | 583 | If walletd and factomd are not both running this call will not work. 584 | 585 | If factomd is not loaded up all the way to last saved block it will 586 | return: 587 | 588 | result:{Factomd Error:Factomd is not fully booted, please wait and 589 | try again.} 590 | 591 | If an address is not in the correct format the call will return: 592 | 593 | result:{Factomd Error:There was an error decoding an address} 594 | 595 | If an address does not have a public and private address known to the 596 | wallet it will not be included in the balance. 597 | 598 | "fctaccountbalances" are the total of all factoid account balances 599 | returned in factoshis. 600 | 601 | "ecaccountbalances" are the total of all entry credit account balances 602 | returned in entry credits. 603 | """ 604 | return self._request("wallet-balances") 605 | 606 | def new_chain( 607 | self, 608 | factomd: Factomd, 609 | ext_ids: List[Union[bytes, str]], 610 | content: Union[bytes, str], 611 | ec_address: str = None, 612 | sleep: float = 1.0, 613 | ): 614 | """ 615 | Shortcut method to create a new chain and initial entry. 616 | 617 | Args: 618 | factomd (Factomd): The `Factomd` instance where the creation message 619 | will be submitted. 620 | ext_ids (List[Union[bytes, str]]): A list of external IDs as 621 | bytes-like objects or hex strings. 622 | content (Union[bytes, str]): Entry content as a bytes like object or 623 | hex string. 624 | ec_address (str): Entry credit address to pay with. If not provided, 625 | `self.ec_address` will be used. 626 | sleep (float): Number of seconds to sleep between chain commit and 627 | reveal. Default is 1.0. 628 | 629 | Returns: 630 | dict: API result from the final `reveal_chain()` call. 631 | """ 632 | calls = self._request( 633 | "compose-chain", 634 | { 635 | "chain": { 636 | "firstentry": { 637 | "extids": [utils.hex_from_bytes_or_string(x) for x in ext_ids], 638 | "content": utils.hex_from_bytes_or_string(content), 639 | } 640 | }, 641 | "ecpub": ec_address or self.ec_address, 642 | }, 643 | ) 644 | factomd.commit_chain(calls["commit"]["params"]["message"]) 645 | time.sleep(sleep) 646 | return factomd.reveal_chain(calls["reveal"]["params"]["entry"]) 647 | 648 | def new_entry( 649 | self, 650 | factomd: Factomd, 651 | chain_id: Union[bytes, str], 652 | ext_ids: List[Union[bytes, str]], 653 | content: Union[bytes, str], 654 | ec_address: str = None, 655 | sleep: float = 1.0, 656 | ): 657 | """ 658 | Shortcut method to create a new entry. 659 | 660 | Args: 661 | factomd (Factomd): The `Factomd` instance where the creation message 662 | will be submitted. 663 | chain_id (Union[bytes, str]): Chain ID where entry will be appended. 664 | ext_ids (List[Union[bytes, str]]): A list of external IDs as 665 | bytes-like objects or hex strings. 666 | content (Union[bytes, str]): Entry content as a bytes like object or 667 | hex string. 668 | ec_address (str): Entry credit address to pay with. If not provided, 669 | `self.ec_address` will be used. 670 | sleep (float): Number of seconds to sleep between entry commit and 671 | reveal. Default is 1.0. 672 | 673 | Returns: 674 | dict: API result from the final `reveal_chain()` call. 675 | """ 676 | calls = self._request( 677 | "compose-entry", 678 | { 679 | "entry": { 680 | "chainid": utils.hex_from_bytes_or_string(chain_id), 681 | "extids": [utils.hex_from_bytes_or_string(x) for x in ext_ids], 682 | "content": utils.hex_from_bytes_or_string(content), 683 | }, 684 | "ecpub": ec_address or self.ec_address, 685 | }, 686 | ) 687 | factomd.commit_entry(calls["commit"]["params"]["message"]) 688 | time.sleep(sleep) 689 | return factomd.reveal_entry(calls["reveal"]["params"]["entry"]) 690 | 691 | def fct_to_ec( 692 | self, 693 | factomd: Factomd, 694 | amount: int, 695 | fct_address: str = None, 696 | ec_address: str = None 697 | ): 698 | """ 699 | Shortcut method to create a factoid to entry credit transaction. 700 | 701 | factomd (Factomd): The `Factomd` instance where the signed transaction 702 | will be submitted. 703 | amount (int): Amount of fct to submit for conversion. You'll likely want 704 | to first query the exchange rate via `Factomd.entry_credit_rate()`. 705 | fct_address (str): Factoid address to pay with. If not provided, 706 | `self.fct_address` will be used. 707 | ec_address (str): Entry credit address to receive credits. If not 708 | provided, `self.ec_address` will be used. 709 | 710 | Returns: 711 | dict: API result from the final `factoid_submit()` call. 712 | """ 713 | name = self._xact_name() 714 | self.new_transaction(name) 715 | self.add_input(name, amount, fct_address) 716 | self.add_ec_output(name, amount, ec_address) 717 | self.add_fee(name, fct_address) 718 | self.sign_transaction(name) 719 | call = self.compose_transaction(name) 720 | return factomd.factoid_submit(call["params"]["transaction"]) 721 | 722 | def fct_to_fct( 723 | self, 724 | factomd: Factomd, 725 | amount: int, 726 | fct_to: str, 727 | fct_from: str = None 728 | ): 729 | """ 730 | Shortcut method to create a factoid to factoid. 731 | 732 | factomd (Factomd): The `Factomd` instance where the signed transaction 733 | will be submitted. 734 | amount (int): Amount of fct to submit for conversion. You'll likely want 735 | to first query the exchange rate via `Factomd.entry_credit_rate()`. 736 | fct_to (str): Output factoid address. 737 | fct_from (str): Input factoid address. If not provided, 738 | `self.fct_address` will be used. 739 | 740 | Returns: 741 | dict: API result from the final `factoid_submit()` call. 742 | """ 743 | name = self._xact_name() 744 | self.new_transaction(name) 745 | self.add_input(name, amount, fct_from) 746 | self.add_output(name, amount, fct_to) 747 | self.add_fee(name, fct_from) 748 | self.sign_transaction(name) 749 | call = self.compose_transaction(name) 750 | return factomd.factoid_submit(call["params"]["transaction"]) 751 | -------------------------------------------------------------------------------- /factom/exceptions.py: -------------------------------------------------------------------------------- 1 | def handle_error_response(resp): 2 | codes = { 3 | -1: FactomAPIError, 4 | -32008: BlockNotFound, 5 | -32009: MissingChainHead, 6 | -32010: ReceiptCreationError, 7 | -32011: RepeatedCommit, 8 | -32600: InvalidRequest, 9 | -32601: MethodNotFound, 10 | -32602: InvalidParams, 11 | -32603: InternalError, 12 | -32700: ParseError, 13 | } 14 | 15 | error = resp.json().get('error', {}) 16 | message = error.get('message') 17 | code = error.get('code', -1) 18 | data = error.get('data', {}) 19 | 20 | raise codes[code](message=message, code=code, data=data, response=resp) 21 | 22 | 23 | class FactomAPIError(Exception): 24 | response = None 25 | data = {} 26 | code = -1 27 | message = "An unknown error occurred" 28 | 29 | def __init__(self, message=None, code=None, data={}, response=None): 30 | self.response = response 31 | if message: 32 | self.message = message 33 | if code: 34 | self.code = code 35 | if data: 36 | self.data = data 37 | 38 | def __str__(self): 39 | if self.code: 40 | return '{}: {}'.format(self.code, self.message) 41 | return self.message 42 | 43 | 44 | class BlockNotFound(FactomAPIError): 45 | pass 46 | 47 | 48 | class MissingChainHead(FactomAPIError): 49 | pass 50 | 51 | 52 | class ReceiptCreationError(FactomAPIError): 53 | pass 54 | 55 | 56 | class RepeatedCommit(FactomAPIError): 57 | pass 58 | 59 | 60 | class InvalidRequest(FactomAPIError): 61 | pass 62 | 63 | 64 | class MethodNotFound(FactomAPIError): 65 | pass 66 | 67 | 68 | class InvalidParams(FactomAPIError): 69 | pass 70 | 71 | 72 | class InternalError(FactomAPIError): 73 | pass 74 | 75 | 76 | class ParseError(FactomAPIError): 77 | pass 78 | -------------------------------------------------------------------------------- /factom/livefeed/__init__.py: -------------------------------------------------------------------------------- 1 | from .listener import LiveFeedListener # noqa 2 | -------------------------------------------------------------------------------- /factom/livefeed/listener.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import socket 3 | import struct 4 | from typing import Callable 5 | 6 | 7 | class LiveFeedListener: 8 | """ 9 | A simple class that listens to Factomd LiveFeed and performs a custom handle 10 | function on each event sent over the feed. 11 | 12 | Args: 13 | handle (callable): A function receiving a bytes-like variable as its 14 | only parameter. Handles all LiveFeed events. 15 | host (str): The host of the LiveFeed to listen to. 16 | port (int): The port on which LiveFeed is configured. 17 | """ 18 | def __init__(self, handle: Callable, host: str = "127.0.0.1", port: int = 8040): 19 | self.handle = handle 20 | self.host = host 21 | self.port = port 22 | 23 | def run(self): 24 | with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: 25 | s.bind((self.host, self.port)) 26 | s.listen(1) 27 | logging.debug("Listening for LiveFeed connection...") 28 | conn, address = s.accept() 29 | with conn: 30 | logging.debug(f"Connected to LiveFeedAPI: {address}") 31 | while True: 32 | protocol_version = conn.recv(1) 33 | if not protocol_version: 34 | break 35 | conn.sendall(protocol_version) 36 | if protocol_version[0] == 1: 37 | next_message_size_bytes = conn.recv(4) 38 | next_message_size = struct.unpack(" 0: 43 | message_data += conn.recv(remaining_bytes) 44 | remaining_bytes = next_message_size - len(message_data) 45 | self.handle(message_data) 46 | -------------------------------------------------------------------------------- /factom/session.py: -------------------------------------------------------------------------------- 1 | from base64 import b64encode 2 | 3 | from requests import Session 4 | 5 | 6 | class FactomAPISession(Session): 7 | 8 | def __init__(self, *args, **kwargs): 9 | """ 10 | Creates a new CoreAPISession instance. 11 | """ 12 | super(FactomAPISession, self).__init__(*args, **kwargs) 13 | 14 | self.headers.update({ 15 | 'Accept-Charset': 'utf-8', 16 | 'Content-Type': 'text/plain', 17 | }) 18 | 19 | def init_basic_auth(self, username, password): 20 | credentials = b64encode('{}:{}'.format(username, password).encode()) 21 | self.headers.update({ 22 | 'Authorization': 'Basic {}'.format(credentials.decode()) 23 | }) 24 | 25 | def init_tls(self, certfile): 26 | self.verify = certfile 27 | 28 | 29 | __all__ = ['FactomAPISession'] 30 | -------------------------------------------------------------------------------- /factom/utils.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | 4 | def hex_from_bytes_or_string(x: Union[bytes, str]): 5 | return x if type(x) is str else x.hex() 6 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | flake8==3.8.* 2 | flake8-isort==3.0.1 3 | isort==4.3.21 4 | pytest==5.4.* 5 | pytest-cov==2.10.* 6 | requests==2.24.* 7 | responses==0.10.* 8 | tox==3.18.* 9 | tox-pyenv==1.1.* 10 | -------------------------------------------------------------------------------- /screenshots/chain.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FactomProject/factom-api/90fa64fdd9eae7ec4e14571893cb5f8ebfc1a946/screenshots/chain.png -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 100 3 | exclude = 4 | tmp, 5 | venv, 6 | .git, 7 | __pycache__, 8 | .tox 9 | 10 | [isort] 11 | lines_after_imports = 2 12 | line_length = 100 13 | multi_line_output = 3 14 | skip = 15 | tmp 16 | 17 | [coverage:run] 18 | source = factom 19 | branch = true 20 | data_file = .coverage 21 | 22 | [coverage:report] 23 | exclude_lines = 24 | coverage: omit 25 | pragma: no-cover 26 | show_missing = True 27 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import find_packages, setup 4 | 5 | 6 | with open('README.md', 'r') as fh: 7 | long_description = fh.read() 8 | 9 | 10 | CLASSIFIERS = [ 11 | "Development Status :: 4 - Beta", 12 | "Intended Audience :: Developers", 13 | "License :: OSI Approved :: MIT License", 14 | "Operating System :: OS Independent", 15 | "Programming Language :: Python :: 3 :: Only", 16 | "Programming Language :: Python :: 3.5", 17 | "Programming Language :: Python :: 3.6", 18 | "Programming Language :: Python :: 3.7", 19 | "Programming Language :: Python :: 3.8", 20 | "Programming Language :: Python :: 3.9", 21 | "Topic :: Internet :: WWW/HTTP", 22 | "Topic :: Security", 23 | "Topic :: Security :: Cryptography", 24 | "Topic :: Software Development", 25 | "Topic :: System :: Monitoring" 26 | ] 27 | 28 | 29 | setup( 30 | author="Ben Homnick", 31 | author_email="bhomnick@gmail.com", 32 | name="factom-api", 33 | version="1.2dev", 34 | description="Python client library for the Factom API", 35 | long_description=long_description, 36 | long_description_content_type='text/markdown', 37 | license="MIT License", 38 | platforms=["OS Independent"], 39 | classifiers=CLASSIFIERS, 40 | packages=find_packages(exclude=["tests"]), 41 | include_package_data=True, 42 | install_requires=[ 43 | "requests>=2.20.0", 44 | ], 45 | url="https://github.com/FactomProject/factom-api", 46 | python_requires='>=3.5' 47 | ) 48 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FactomProject/factom-api/90fa64fdd9eae7ec4e14571893cb5f8ebfc1a946/tests/__init__.py -------------------------------------------------------------------------------- /tests/integration/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | import responses as responses_ 5 | 6 | 7 | def assert_jsonrpc_calls(resp_mock, calls): 8 | assert len(calls) == len(resp_mock.calls), "Unexpected call count" 9 | for call, expected in zip(resp_mock.calls, calls): 10 | data = json.loads(call.request.body.decode()) 11 | assert expected == (data['method'], data['params']) 12 | 13 | 14 | # flake8: noqa 15 | FACTOMD_RESPONSES = { 16 | 'chain-head': { 17 | 'chainhead': '8d87077d6d35f225c74e7a7cbcd9538cdb5642f5541ba77fc815f3b57ac10eb6', 18 | 'chaininprocesslist': False 19 | }, 20 | 'commit-chain': { 21 | 'chainidhash': 'ff5d4299149cc84ed78cf4a4375d70ec506a5d79f229df9e9872302c3f8e7658', 22 | 'entryhash': '7a6d60d93b0284b1a8827313db23d47f5894b409593c3751302ceedf44169c45', 23 | 'message': 'Chain Commit Success', 24 | 'txid': 'ca0e81e93b3f44790aad767221b7edf9c03b6edd50dec7cbcb40d04a779d780b' 25 | }, 26 | 'commit-entry': { 27 | 'entryhash': '8d9eba64b972c217aae1d434926e8f855f9b88f7e061156f7ed5482fc52c7f52', 28 | 'message': 'Entry Commit Success', 29 | 'txid': 'c0ac46ebbb268621bbfaa0f9fcb88705041db8418b7dfd3476f120661244af3a' 30 | }, 31 | 'entry': { 32 | 'chainid': '1726b29c0b0576e4451f348922551152b044d864690786117fde360845508c63', 33 | 'content': '636861696e5f636f6e74656e74', 34 | 'extids': ['636861696e', '6964'] 35 | }, 36 | 'entry-block': { 37 | 'entrylist': [ 38 | { 39 | 'entryhash': '7a6d60d93b0284b1a8827313db23d47f5894b409593c3751302ceedf44169c45', 40 | 'timestamp': 1512902940 41 | } 42 | ], 43 | 'header': { 44 | 'blocksequencenumber': 0, 45 | 'chainid': '1726b29c0b0576e4451f348922551152b044d864690786117fde360845508c63', 46 | 'dbheight': 537, 47 | 'prevkeymr': '0000000000000000000000000000000000000000000000000000000000000000', 48 | 'timestamp': 1512902460 49 | } 50 | }, 51 | 'entry-credit-balance': { 52 | 'balance': 100 53 | }, 54 | 'entry-credit-rate': { 55 | 'rate': 1000 56 | }, 57 | 'factoid-balance': { 58 | 'balance': 2000000000000 59 | }, 60 | 'factoid-submit': { 61 | 'message': 'Successfully submitted the transaction', 62 | 'txid': 'baedcf21a3308eca617c1a54a0b001aa732986e7eae9eb2e219000f5ebbcaf03' 63 | }, 64 | 'reveal-chain': { 65 | 'chainid': '1726b29c0b0576e4451f348922551152b044d864690786117fde360845508c63', 66 | 'entryhash': '7a6d60d93b0284b1a8827313db23d47f5894b409593c3751302ceedf44169c45', 67 | 'message': 'Entry Reveal Success' 68 | }, 69 | 'reveal-entry': { 70 | 'chainid': '1726b29c0b0576e4451f348922551152b044d864690786117fde360845508c63', 71 | 'entryhash': '8d9eba64b972c217aae1d434926e8f855f9b88f7e061156f7ed5482fc52c7f52', 72 | 'message': 'Entry Reveal Success' 73 | } 74 | } 75 | 76 | 77 | # flake8: noqa 78 | WALLETD_RESPONSES = { 79 | 'add-ec-output': { 80 | 'ecoutputs': [ 81 | { 82 | 'address': 'EC1rs7S56bWgTXN8XvaqhFenzRoHiUpHV2dYvwS7cJpqfb9HaRhi', 83 | 'amount': 50000 84 | } 85 | ], 86 | 'feesrequired': 12000, 87 | 'inputs': [ 88 | { 89 | 'address': 'FA2jK2HcLnRdS94dEcU27rF3meoJfpUcZPSinpb7AwQvPRY6RL1Q', 90 | 'amount': 50000 91 | } 92 | ], 93 | 'name': 'TX_5XK1IX', 94 | 'outputs': None, 95 | 'signed': False, 96 | 'timestamp': 1512899995, 97 | 'totalecoutputs': 50000, 98 | 'totalinputs': 50000, 99 | 'totaloutputs': 0, 100 | 'txid': 'ae25de26e1db736936e5b0ee6d8e8f87915ffd8cbb2c1b753278974c5a23174b' 101 | }, 102 | 'add-fee': { 103 | 'ecoutputs': [ 104 | { 105 | 'address': 'EC1rs7S56bWgTXN8XvaqhFenzRoHiUpHV2dYvwS7cJpqfb9HaRhi', 106 | 'amount': 50000 107 | } 108 | ], 109 | 'feespaid': 12000, 110 | 'feesrequired': 12000, 111 | 'inputs': [ 112 | { 113 | 'address': 'FA2jK2HcLnRdS94dEcU27rF3meoJfpUcZPSinpb7AwQvPRY6RL1Q', 114 | 'amount': 62000 115 | } 116 | ], 117 | 'name': 'TX_5XK1IX', 118 | 'outputs': None, 119 | 'signed': False, 120 | 'timestamp': 1512899995, 121 | 'totalecoutputs': 50000, 122 | 'totalinputs': 62000, 123 | 'totaloutputs': 0, 124 | 'txid': 'baedcf21a3308eca617c1a54a0b001aa732986e7eae9eb2e219000f5ebbcaf03' 125 | }, 126 | 'add-input': { 127 | 'ecoutputs': None, 128 | 'feespaid': 50000, 129 | 'feesrequired': 2000, 130 | 'inputs': [ 131 | { 132 | 'address': 'FA2jK2HcLnRdS94dEcU27rF3meoJfpUcZPSinpb7AwQvPRY6RL1Q', 133 | 'amount': 50000 134 | } 135 | ], 136 | 'name': 'TX_5XK1IX', 137 | 'outputs': None, 138 | 'signed': False, 139 | 'timestamp': 1512899995, 140 | 'totalecoutputs': 0, 141 | 'totalinputs': 50000, 142 | 'totaloutputs': 0, 143 | 'txid': '7a7e5782dc5e26e242dee09aeccf77e9ce0531e3a17c95fba822fd35679a41eb' 144 | }, 145 | 'add-output': { 146 | 'ecoutputs': None, 147 | 'feesrequired': 12000, 148 | 'inputs': [ 149 | { 150 | 'address': 'FA2jK2HcLnRdS94dEcU27rF3meoJfpUcZPSinpb7AwQvPRY6RL1Q', 151 | 'amount': 50000 152 | } 153 | ], 154 | 'name': 'TX_5XK1IX', 155 | 'outputs': [ 156 | { 157 | 'address': 'FA39PymAz9pqBPTQkupT7g72THwbM2XyRrUpodvBJHKWGLjpJAd5', 158 | 'amount': 50000 159 | } 160 | ], 161 | 'signed': False, 162 | 'timestamp': 1512900560, 163 | 'totalecoutputs': 0, 164 | 'totalinputs': 50000, 165 | 'totaloutputs': 50000, 166 | 'txid': '2ed27042eba5553516812fdfe581c53e0800efd0dfaee1f9019076c57e194abe' 167 | }, 168 | 'compose-chain': { 169 | 'commit': { 170 | 'id': 76, 171 | 'jsonrpc': '2.0', 172 | 'method': 'commit-chain', 173 | 'params': {'message': '00016040044481ff5d4299149cc84ed78cf4a4375d70ec506a5d79f229df9e9872302c3f8e7658384848597c35f8add855cce0fe3878c6fdff1b48003aaf6193ad7fbbd100ad1e7a6d60d93b0284b1a8827313db23d47f5894b409593c3751302ceedf44169c450b0cf8b115fc135b45b9f11e2aff638591cb382e238b4d31e4a3de4912a69740ffa39a8544c36048b2f1a63873abc0e8a8ac3fc709d222270509a6d295ea6d4db4b8833c64d48e8d189b0aaf4ff518b906c3208b5e761527c1433f5d14c638830f'} 174 | }, 175 | 'reveal': { 176 | 'id': 77, 177 | 'jsonrpc': '2.0', 178 | 'method': 'reveal-chain', 179 | 'params': {'entry': '001726b29c0b0576e4451f348922551152b044d864690786117fde360845508c63000b0005636861696e00026964636861696e5f636f6e74656e74'} 180 | } 181 | }, 182 | 'compose-entry': { 183 | 'commit': { 184 | 'id': 80, 185 | 'jsonrpc': '2.0', 186 | 'method': 'commit-entry', 187 | 'params': {'message': '000160400778608d9eba64b972c217aae1d434926e8f855f9b88f7e061156f7ed5482fc52c7f52010cf8b115fc135b45b9f11e2aff638591cb382e238b4d31e4a3de4912a69740ffd0472bbe1e345f6a2435a85c8d071fab7cbd6554d323689f418f6a5fb97d4d0c5fbf41109853a9d7b6b9fdb802eb558d95bce64d5574af7b89c4c3e7ce6af70b'} 188 | }, 189 | 'reveal': { 190 | 'id': 81, 191 | 'jsonrpc': '2.0', 192 | 'method': 'reveal-entry', 193 | 'params': {'entry': '001726b29c0b0576e4451f348922551152b044d864690786117fde360845508c63000b0005656e74727900026964656e7472795f636f6e74656e74'} 194 | } 195 | }, 196 | 'compose-transaction': { 197 | 'id': 2, 198 | 'jsonrpc': '2.0', 199 | 'method': 'factoid-submit', 200 | 'params': { 201 | 'transaction': '0201603fdde75301000183e430646f3e8750c550e4582eca5047546ffef89c13a175985e320232bacac81cc4288386500cf8b115fc135b45b9f11e2aff638591cb382e238b4d31e4a3de4912a69740ff01718b5edd2914acc2e4677f336c1a32736e5e9bde13663e6413894f57ec272e2866a77c4d8b128266f0431170d65f2aa742b71b6d9674e690d16af344353af7ef5792f4dee744012afce1465897a8f7d509a951aca7c12ca60df03119c78df607' 202 | } 203 | }, 204 | 'new-transaction': { 205 | 'ecoutputs': None, 206 | 'feesrequired': 1000, 207 | 'inputs': None, 208 | 'name': 'TX_5XK1IX', 209 | 'outputs': None, 210 | 'signed': False, 211 | 'timestamp': 1512899995, 212 | 'totalecoutputs': 0, 213 | 'totalinputs': 0, 214 | 'totaloutputs': 0, 215 | 'txid': 'a3357318ed4eb8da89544b6a55023cef38f208c5601650f802b99932b77d963f' 216 | }, 217 | 'sign-transaction': { 218 | 'ecoutputs': [ 219 | { 220 | 'address': 'EC1rs7S56bWgTXN8XvaqhFenzRoHiUpHV2dYvwS7cJpqfb9HaRhi', 221 | 'amount': 50000 222 | } 223 | ], 224 | 'feespaid': 12000, 225 | 'feesrequired': 12000, 226 | 'inputs': [ 227 | { 228 | 'address': 'FA2jK2HcLnRdS94dEcU27rF3meoJfpUcZPSinpb7AwQvPRY6RL1Q', 229 | 'amount': 62000 230 | } 231 | ], 232 | 'name': 'TX_5XK1IX', 233 | 'outputs': None, 234 | 'signed': True, 235 | 'timestamp': 1512899995, 236 | 'totalecoutputs': 50000, 237 | 'totalinputs': 62000, 238 | 'totaloutputs': 0, 239 | 'txid': 'baedcf21a3308eca617c1a54a0b001aa732986e7eae9eb2e219000f5ebbcaf03' 240 | } 241 | } 242 | 243 | 244 | def _callback(choices): 245 | def _handle(request): 246 | method = json.loads(request.body.decode())['method'] 247 | return (200, {}, json.dumps({ 248 | 'jsonrpc': '2.0', 249 | 'id': 0, 250 | 'result': choices[method] 251 | })) 252 | return _handle 253 | 254 | 255 | @pytest.fixture() 256 | def responses(): 257 | with responses_.RequestsMock(assert_all_requests_are_fired=False) as resp_mock: # noqa 258 | resp_mock.add_callback('POST', 'http://localhost:8088/v2', 259 | callback=_callback(FACTOMD_RESPONSES)) 260 | resp_mock.add_callback('POST', 'http://localhost:8089/v2', 261 | callback=_callback(WALLETD_RESPONSES)) 262 | yield resp_mock 263 | -------------------------------------------------------------------------------- /tests/integration/test_api.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | import pytest 4 | 5 | from factom.client import Factomd, FactomWalletd 6 | 7 | from . import assert_jsonrpc_calls, responses # noqa 8 | 9 | 10 | FA_1 = 'FA2jK2HcLnRdS94dEcU27rF3meoJfpUcZPSinpb7AwQvPRY6RL1Q' 11 | FA_2 = 'FA39PymAz9pqBPTQkupT7g72THwbM2XyRrUpodvBJHKWGLjpJAd5' 12 | EC_1 = 'EC1rs7S56bWgTXN8XvaqhFenzRoHiUpHV2dYvwS7cJpqfb9HaRhi' 13 | TX = '0201603fdde75301000183e430646f3e8750c550e4582eca5047546ffef89c13a175985e320232bacac81cc4288386500cf8b115fc135b45b9f11e2aff638591cb382e238b4d31e4a3de4912a69740ff01718b5edd2914acc2e4677f336c1a32736e5e9bde13663e6413894f57ec272e2866a77c4d8b128266f0431170d65f2aa742b71b6d9674e690d16af344353af7ef5792f4dee744012afce1465897a8f7d509a951aca7c12ca60df03119c78df607' # noqa 14 | CHAIN_ID = '1726b29c0b0576e4451f348922551152b044d864690786117fde360845508c63' 15 | ENTRY_1 = '7a6d60d93b0284b1a8827313db23d47f5894b409593c3751302ceedf44169c45' 16 | ENTRY_2 = '8d9eba64b972c217aae1d434926e8f855f9b88f7e061156f7ed5482fc52c7f52' 17 | COMMIT_CHAIN_MSG = '00016040044481ff5d4299149cc84ed78cf4a4375d70ec506a5d79f229df9e9872302c3f8e7658384848597c35f8add855cce0fe3878c6fdff1b48003aaf6193ad7fbbd100ad1e7a6d60d93b0284b1a8827313db23d47f5894b409593c3751302ceedf44169c450b0cf8b115fc135b45b9f11e2aff638591cb382e238b4d31e4a3de4912a69740ffa39a8544c36048b2f1a63873abc0e8a8ac3fc709d222270509a6d295ea6d4db4b8833c64d48e8d189b0aaf4ff518b906c3208b5e761527c1433f5d14c638830f' # noqa 18 | REVEAL_CHAIN_MSG = '001726b29c0b0576e4451f348922551152b044d864690786117fde360845508c63000b0005636861696e00026964636861696e5f636f6e74656e74' # noqa 19 | COMMIT_ENTRY_MSG = '000160400778608d9eba64b972c217aae1d434926e8f855f9b88f7e061156f7ed5482fc52c7f52010cf8b115fc135b45b9f11e2aff638591cb382e238b4d31e4a3de4912a69740ffd0472bbe1e345f6a2435a85c8d071fab7cbd6554d323689f418f6a5fb97d4d0c5fbf41109853a9d7b6b9fdb802eb558d95bce64d5574af7b89c4c3e7ce6af70b' # noqa 20 | REVEAL_ENTRY_MSG = '001726b29c0b0576e4451f348922551152b044d864690786117fde360845508c63000b0005656e74727900026964656e7472795f636f6e74656e74' # noqa 21 | ENTRY_KEYMR = '8d87077d6d35f225c74e7a7cbcd9538cdb5642f5541ba77fc815f3b57ac10eb6' # noqa 22 | 23 | 24 | @pytest.fixture 25 | def factomd(): 26 | return Factomd(ec_address=EC_1, fct_address=FA_1) 27 | 28 | 29 | @pytest.fixture 30 | def walletd(): 31 | with patch.object(FactomWalletd, '_xact_name', return_value='TX_5XK1IX'): 32 | yield FactomWalletd(ec_address=EC_1, fct_address=FA_1) 33 | 34 | 35 | def test_fct_to_ec(responses, factomd, walletd): # noqa 36 | res = walletd.fct_to_ec(factomd, 50000) 37 | 38 | assert res == { 39 | 'message': 'Successfully submitted the transaction', 40 | 'txid': 'baedcf21a3308eca617c1a54a0b001aa732986e7eae9eb2e219000f5ebbcaf03' # noqa 41 | } 42 | assert_jsonrpc_calls(responses, [ 43 | ('new-transaction', {'tx-name': 'TX_5XK1IX'}), 44 | ('add-input', { 45 | 'tx-name': 'TX_5XK1IX', 46 | 'address': FA_1, 47 | 'amount': 50000 48 | }), 49 | ('add-ec-output', { 50 | 'tx-name': 'TX_5XK1IX', 51 | 'address': EC_1, 52 | 'amount': 50000 53 | }), 54 | ('add-fee', {'tx-name': 'TX_5XK1IX', 'address': FA_1}), 55 | ('sign-transaction', {'tx-name': 'TX_5XK1IX', 'force': False}), 56 | ('compose-transaction', {'tx-name': 'TX_5XK1IX'}), 57 | ('factoid-submit', {'transaction': TX}) 58 | ]) 59 | 60 | 61 | def test_fct_to_fct(responses, factomd, walletd): # noqa 62 | res = walletd.fct_to_fct(factomd, 50000, FA_2) 63 | 64 | assert res == { 65 | 'message': 'Successfully submitted the transaction', 66 | 'txid': 'baedcf21a3308eca617c1a54a0b001aa732986e7eae9eb2e219000f5ebbcaf03' # noqa 67 | } 68 | assert_jsonrpc_calls(responses, [ 69 | ('new-transaction', {'tx-name': 'TX_5XK1IX'}), 70 | ('add-input', { 71 | 'tx-name': 'TX_5XK1IX', 72 | 'address': FA_1, 73 | 'amount': 50000 74 | }), 75 | ('add-output', { 76 | 'tx-name': 'TX_5XK1IX', 77 | 'address': FA_2, 78 | 'amount': 50000 79 | }), 80 | ('add-fee', {'tx-name': 'TX_5XK1IX', 'address': FA_1}), 81 | ('sign-transaction', {'tx-name': 'TX_5XK1IX', 'force': False}), 82 | ('compose-transaction', {'tx-name': 'TX_5XK1IX'}), 83 | ('factoid-submit', {'transaction': TX}) 84 | ]) 85 | 86 | 87 | def test_new_chain(responses, factomd, walletd): # noqa 88 | res = walletd.new_chain(factomd, [b'chain', b'id'], b'chain_content') 89 | 90 | assert res == { 91 | 'chainid': CHAIN_ID, 92 | 'entryhash': ENTRY_1, 93 | 'message': 'Entry Reveal Success' 94 | } 95 | assert_jsonrpc_calls(responses, [ 96 | ('compose-chain', { 97 | 'chain': { 98 | 'firstentry': { 99 | 'extids': ['636861696e', '6964'], 100 | 'content': '636861696e5f636f6e74656e74' 101 | } 102 | }, 103 | 'ecpub': EC_1 104 | }), 105 | ('commit-chain', {'message': COMMIT_CHAIN_MSG}), 106 | ('reveal-chain', {'entry': REVEAL_CHAIN_MSG}) 107 | ]) 108 | 109 | 110 | def test_new_entry(responses, factomd, walletd): # noqa 111 | res = walletd.new_entry(factomd, CHAIN_ID, [b'entry', b'id'], 112 | b'entry_content') 113 | 114 | assert res == { 115 | 'chainid': CHAIN_ID, 116 | 'entryhash': ENTRY_2, 117 | 'message': 'Entry Reveal Success' 118 | } 119 | assert_jsonrpc_calls(responses, [ 120 | ('compose-entry', { 121 | 'entry': { 122 | 'chainid': CHAIN_ID, 123 | 'extids': ['656e747279', '6964'], 124 | 'content': '656e7472795f636f6e74656e74' 125 | }, 126 | 'ecpub': EC_1 127 | }), 128 | ('commit-entry', {'message': COMMIT_ENTRY_MSG}), 129 | ('reveal-entry', {'entry': REVEAL_ENTRY_MSG}) 130 | ]) 131 | 132 | 133 | def test_read_chain(responses, factomd, walletd): # noqa 134 | res = list(factomd.read_chain(CHAIN_ID)) 135 | 136 | assert res == [{ 137 | 'chainid': CHAIN_ID, 138 | 'extids': [b'chain', b'id'], 139 | 'content': b'chain_content' 140 | }] 141 | assert_jsonrpc_calls(responses, [ 142 | ('chain-head', {'chainid': CHAIN_ID}), 143 | ('entry-block', {'keymr': ENTRY_KEYMR}), 144 | ('entry', {'hash': ENTRY_1}) 145 | ]) 146 | -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | from factom.client import BaseAPI 2 | 3 | 4 | def test_init(): 5 | c = BaseAPI(ec_address='EC_ADDR', fct_address='FA_ADDR', 6 | host='http://somehost/', version='v2', username='user', 7 | password='pass', certfile='/cert.pem') 8 | 9 | assert c.ec_address == 'EC_ADDR' 10 | assert c.fct_address == 'FA_ADDR' 11 | assert c.host == 'http://somehost/' 12 | assert 'Authorization' in c.session.headers 13 | assert c.session.verify == '/cert.pem' 14 | 15 | 16 | def test_url(): 17 | c = BaseAPI(host='http://somehost', version='v3') 18 | 19 | assert c.url == 'http://somehost/v3' 20 | -------------------------------------------------------------------------------- /tests/test_exceptions.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | from requests import Response 5 | 6 | from factom.exceptions import InvalidRequest, handle_error_response 7 | 8 | 9 | def _response(content): 10 | r = Response() 11 | r._content = json.dumps(content).encode() 12 | return r 13 | 14 | 15 | def test_handle_error_response(): 16 | r = _response({'error': { 17 | 'code': -32600, 18 | 'message': "Invalid request", 19 | 'data': 'field: field invalid' 20 | }}) 21 | 22 | with pytest.raises(InvalidRequest) as exc_info: 23 | handle_error_response(r) 24 | 25 | e = exc_info.value 26 | assert str(e) == '-32600: Invalid request' 27 | assert e.data == 'field: field invalid' 28 | assert e.response == r 29 | -------------------------------------------------------------------------------- /tests/test_session.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from requests import Request 3 | 4 | from factom.session import FactomAPISession 5 | 6 | 7 | @pytest.fixture 8 | def fake_request(): 9 | return Request('GET', 'http://someurl/') 10 | 11 | 12 | def test_request_headers(fake_request): 13 | s = FactomAPISession() 14 | r = s.prepare_request(fake_request) 15 | 16 | assert r.headers['Accept-Charset'] == 'utf-8' 17 | assert r.headers['Content-Type'] == 'text/plain' 18 | 19 | 20 | def test_basic_auth(fake_request): 21 | s = FactomAPISession() 22 | s.init_basic_auth('user', 'pass') 23 | r = s.prepare_request(fake_request) 24 | 25 | assert r.headers['Authorization'] == 'Basic dXNlcjpwYXNz' 26 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py35,py36,py37,py38 3 | 4 | [testenv] 5 | deps = 6 | pytest 7 | pytest-cov 8 | responses 9 | commands = pytest 10 | --------------------------------------------------------------------------------