├── .coveragerc ├── .gitignore ├── .travis.yml ├── CONTRIBUTING.md ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── kin.png ├── kin ├── __init__.py ├── config.py ├── errors.py ├── sdk.py ├── stellar │ ├── __init__.py │ ├── builder.py │ ├── channel_manager.py │ ├── errors.py │ ├── horizon.py │ ├── horizon_models.py │ └── utils.py └── version.py ├── requirements-dev.txt ├── requirements.txt ├── setup.cfg ├── setup.py ├── stellar.png └── test ├── conftest.py ├── test_builder.py ├── test_errors.py ├── test_horizon.py ├── test_sdk.py └── test_utils.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | include = kin/* 4 | omit = test/* 5 | 6 | [report] 7 | exclude_lines = 8 | pragma: no cover 9 | raise AssertionError 10 | raise NotImplementedError 11 | if __name__ == .__main__.: 12 | ignore_errors = True 13 | 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.pyc 3 | .idea 4 | .coverage 5 | *.pyc 6 | *.log 7 | MANIFEST 8 | .cache/ 9 | .pytest_cache/ 10 | docs/build 11 | dist/ 12 | virtualenv/ 13 | 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | language: python 3 | python: 4 | - "2.7" 5 | - "3.6" 6 | 7 | services: 8 | - docker 9 | 10 | cache: 11 | - pip 12 | 13 | before_install: 14 | - docker pull zulucrypto/stellar-integration-test-network 15 | - make start 16 | - docker ps -a 17 | 18 | install: 19 | - make init 20 | 21 | script: 22 | - make test 23 | 24 | after_success: 25 | - codecov --token=f644f5f2-b455-4e5c-8ec6-fefce1fc587a 26 | 27 | after_script: 28 | - make stop 29 | 30 | notifications: 31 | email: false 32 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thank you for your interest in the project. We look forward to your contribution. In order to make the process as fast 4 | and streamlined as possible, here is a set of guidelines we recommend you follow. 5 | 6 | ## Reporting Issues 7 | First of all, please be sure to check our documentation and [issue archive](https://github.com/kinecosystem/kin-core-python/issues) 8 | to find out if your issue has already been addressed, or is currently being looked at. 9 | 10 | To start a discussion around a bug or a feature, [open a new issue](https://github.com/kinecosystem/kin-core-python/issues/new). 11 | When opening an issue, please provide the following information: 12 | 13 | - SDK version and Python version 14 | - OS version 15 | - The issue you are encountering including a stacktrace if applicable 16 | - Steps or a code snippet to reproduce the issue 17 | 18 | For feature requests it is encouraged to include sample code highlighting a use case for the new feature. 19 | 20 | ## Use Github Pull Requests 21 | 22 | All potential code changes should be submitted as pull requests on Github. A pull request should only include 23 | commits directly applicable to its change (e.g. a pull request that adds a new feature should not include PEP8 changes in 24 | an unrelated area of the code). Please check the following guidelines for submitting a new pull request. 25 | 26 | - Ensure that nothing has been broken by your changes by running the test suite. You can do so by running 27 | `make test` in the project root. 28 | - Write clear, self-contained commits. Your commit message should be concise and describe the nature of the change. 29 | - Rebase proactively. Your pull request should be up to date against the current master branch. 30 | - Add tests. All non-trivial changes should include full test coverage. Make sure there are relevant tests to 31 | ensure the code you added continues to work as the project evolves. 32 | - Add docs. This usually applies to new features rather than bug fixes, but new behavior should always be documented. 33 | - Follow the coding style described below. 34 | - Ask questions. If you are confused about something pertaining to the project, feel free to communicate with us. 35 | 36 | ### Code Style 37 | 38 | Code *must* be compliant with [PEP 8](https://www.python.org/dev/peps/pep-0008/). Use the latest version of 39 | [PEP8](https://pypi.python.org/pypi/pep8) or [flake8](https://pypi.python.org/pypi/flake8) to catch issues. 40 | 41 | Git commit messages should include [a summary and proper line wrapping](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html) 42 | 43 | ## Development 44 | If you are looking to contribute to this SDK, here are the steps to get you started. 45 | 46 | 1. Fork this project 47 | 2. Setup your Python [virtual environment](http://docs.python-guide.org/en/latest/dev/virtualenvs): 48 | ```bash 49 | $ mkvirtualenv kin-core-python 50 | $ workon kin-core-python 51 | ``` 52 | 3. Setup `pip` and `npm` dependencies: 53 | ```bash 54 | $ make init 55 | ``` 56 | 4. Work on code 57 | 5. Test your code locally 58 | ```bash 59 | # start your local testnet 60 | $ make start 61 | # run tests 62 | $ make test 63 | # stop the testnet 64 | $ make stop 65 | ``` 66 | 6. Test your code with live testnet 67 | ```bash 68 | $ make testnet 69 | ``` 70 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Kin Foundation 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 | recursive-include kin *.py 4 | 5 | global-exclude .DS_Store 6 | recursive-exclude test * 7 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | # default target does nothing 3 | .DEFAULT_GOAL: default 4 | default: ; 5 | 6 | init: 7 | pip install -r requirements.txt 8 | pip install -r requirements-dev.txt 9 | .PHONY: init 10 | 11 | start: 12 | docker run --rm -d -p "8000:8000" --name stellar zulucrypto/stellar-integration-test-network 13 | sleep 5 14 | .PHONY: start 15 | 16 | stop: 17 | docker stop stellar 18 | .PHONY: stop 19 | 20 | testnet: 21 | python -m pytest -v -rs --cov=kin -s -x test --testnet 22 | .PHONY: testnet 23 | 24 | test: 25 | python -m pytest -v -rs --cov=kin -s -x test 26 | .PHONY: test 27 | 28 | wheel: 29 | python setup.py bdist_wheel 30 | .PHONY: wheel 31 | 32 | pypi: 33 | twine upload dist/* 34 | .PHONY: pypi 35 | 36 | clean: 37 | rm -f .coverage 38 | find . -name \*.pyc -delete 39 | .PHONY: clean 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # This branch is deprecated, and no longer maintained. The current branch for the kin-sdk is "v2-master". (Will move to master at a later date) 2 | 3 | ![Kin Logo](kin.png) ![Stelalr Logo](stellar.png) 4 | 5 | # KIN Python SDK for Stellar Blockchain 6 | [![Build Status](https://travis-ci.org/kinecosystem/kin-core-python.svg?branch=master)](https://travis-ci.org/kinecosystem/kin-core-python) [![Coverage Status](https://codecov.io/gh/kinecosystem/kin-core-python/branch/master/graph/badge.svg)](https://codecov.io/gh/kinecosystem/kin-core-python) 7 | 8 | ## Disclaimer 9 | 10 | The SDK is still in beta. No warranties are given, use on your own discretion. 11 | 12 | ## Requirements. 13 | 14 | Make sure you have Python 2 >=2.7.9. 15 | 16 | ## Installation 17 | 18 | ```bash 19 | pip install git+https://github.com/kinecosystem/kin-core-python.git 20 | ``` 21 | 22 | ## Usage 23 | 24 | ### Initialization 25 | 26 | To initialize the SDK, you need to provide the following parameters: 27 | - (optional) the secret key to init the internal SDK wallet with. If not provided, you will NOT be able to use the 28 | following functions: `get_address`, `get_native_balance`, `get_kin_balance`, `create_account`, `monitor_kin_payments`. 29 | - (optional) the endpoint URI of your [Horizon](https://www.stellar.org/developers/horizon/reference/) node. 30 | If not provided, a default Horizon endpoint will be used, either a testnet or pubnet, depending on the `network` 31 | parameter below. 32 | - (optional) a network identifier, which is either `PUBLIC` or `TESTNET`, defaults to `PUBLIC`. 33 | - (optional) a list of channel keys. If provided, the channel accounts will be used to sign transactions instead 34 | of the internal SDK wallet. Use it to insure higher concurrency. 35 | 36 | 37 | ```python 38 | import kin 39 | 40 | # Init SDK without a secret key, in the public Stellar network (for generic blockchain queries) 41 | sdk = kin.SDK() 42 | 43 | # Init SDK without a secret key, for Stellar testnet 44 | sdk = kin.SDK(network='TESTNET') 45 | 46 | # Init SDK without a secret key, with specific Horizon server, running on Stellar testnet 47 | sdk = kin.SDK(horizon_endpoint_uri='http://my.horizon.uri', network='TESTNET') 48 | 49 | # Init SDK with wallet secret key, on public network 50 | sdk = kin.SDK(secret_key='my key') 51 | 52 | # Init SDK with several channels, on public network 53 | sdk = kin.SDK(secret_key='my key', channel_secret_keys=['key1', 'key2', ...]) 54 | ``` 55 | For more examples, see the [SDK test file](test/test_sdk.py). 56 | 57 | 58 | ### Getting Wallet Details 59 | ```python 60 | # Get the address of my wallet account. The address is derived from the secret key the SDK was inited with. 61 | address = sdk.get_address() 62 | ``` 63 | 64 | ### Getting Account Balance 65 | ```python 66 | # Get native (lumen) balance of the SDK wallet 67 | native_balance = sdk.get_native_balance() 68 | 69 | # Get KIN balance of the SDK wallet 70 | kin_balance = sdk.get_kin_balance() 71 | 72 | # Get native (lumen) balance of some account 73 | native_balance = sdk.get_account_native_balance('address') 74 | 75 | # Get KIN balance of some account 76 | kin_balance = sdk.get_account_kin_balance('address') 77 | ``` 78 | 79 | ### Getting Account Data 80 | ```python 81 | # returns kin.AccountData 82 | account_data = sdk.get_account_data('address') 83 | ``` 84 | 85 | ### Checking If Account Exists 86 | ```python 87 | account_exists = sdk.check_account_exists('address') 88 | ``` 89 | 90 | ### Creating a New Account 91 | ```python 92 | # create a new account prefunded with MIN_ACCOUNT_BALANCE lumens 93 | tx_hash = sdk.create_account('address') 94 | 95 | # create a new account prefunded with a specified amount of native currency (lumens). 96 | tx_hash = sdk.create_account('address', starting_balance=1000) 97 | 98 | # create a new activated account 99 | tx_hash = sdk.create_account('address', starting_balance=1000, activate=True) 100 | ``` 101 | ### Checking if Account is Activated (Trustline established) 102 | ```python 103 | # check if KIN is trusted by some account 104 | kin_trusted = sdk.check_account_activated('address') 105 | ``` 106 | 107 | ### Sending Currency 108 | ```python 109 | # send native currency (lumens) to some address 110 | tx_hash = sdk.send_native('address', 100, memo_text='order123') 111 | 112 | # send KIN to some address 113 | tx_hash = sdk.send_kin('address', 1000, memo_text='order123') 114 | ``` 115 | 116 | ### Getting Transaction Data 117 | ```python 118 | # create a transaction, for example a new account 119 | tx_hash = sdk.create_account('address') 120 | # get transaction data, returns kin.TransactionData 121 | tx_data = sdk.get_transaction_data(tx_hash) 122 | ``` 123 | 124 | ### Transaction Monitoring 125 | ```python 126 | # define a callback function that receives an address and a kin.TransactionData object 127 | def print_callback(address, tx_data): 128 | print(address, tx_data) 129 | 130 | # start monitoring KIN payments related to the SDK wallet account 131 | sdk.monitor_kin_payments(print_callback) 132 | 133 | # start monitoring KIN payments related to a list of addresses 134 | sdk.monitor_accounts_kin_payments(['address1', 'address2'], print_callback) 135 | 136 | # start monitoring all transactions related to a list of addresses 137 | sdk.monitor_accounts_transactions(['address1', 'address2'], print_callback) 138 | ``` 139 | 140 | #### Receiving Payments from Users 141 | Let us consider a real-life case when you need to receive payments from users for the orders they make. 142 | In order to associate a transaction with an order, we will use the `TransactionData.memo` field: 143 | 144 | ```python 145 | # setup your orders cache 146 | orders = {} 147 | 148 | # define a callback function that validates payments and marks orders as completed 149 | def payment_callback(address, tx_data): 150 | order_id = tx_data.memo 151 | if order_id not in orders: 152 | logging.warn('order not found: {}'.format(order_id)) 153 | return 154 | 155 | order = orders[order_id] 156 | 157 | # check that the order is not yet completed 158 | if order['completed'] is not None: 159 | logging.warn('order {} is already completed'.format(order_id)) 160 | return 161 | 162 | # check that the amount matches 163 | if tx_data.operations[0].amount != order['amount']: 164 | logging.warn('wrong amount paid for order {}: received {}, need {}' 165 | .format(order_id, tx_data.operations[0].amount, order['amount'])) 166 | return 167 | 168 | # all good 169 | order['completed'] = datetime.now() 170 | 171 | 172 | # start monitoring KIN payments related to the SDK wallet account 173 | sdk.monitor_kin_payments(payment_callback) 174 | 175 | # when an order comes, store its data in the cache 176 | order_id = generate_order_id() 177 | orders[order_id] = { 178 | 'user_id': user_id, 179 | 'product_id': product_id, 180 | 'amount': product_cost, 181 | 'created': datetime.now(), 182 | 'completed': None 183 | } 184 | 185 | # now pass this order_id to the user and have him insert it into the memo field of his transaction. 186 | # After he submits the transaction, the payment_callback above will catch it and update the order data. 187 | ``` 188 | 189 | ### Checking Status 190 | The handy `get_status` method will return some parameters the SDK was configured with, along with Horizon status: 191 | ```python 192 | status = sdk.get_status() 193 | print status 194 | # { 195 | # 'sdk_version': '0.2.0', 196 | # 'channels': { 197 | # 'all': 5, 198 | # 'free': 5 199 | # }, 200 | # 'kin_asset': { 201 | # 'code': 'KIN', 202 | # 'issuer': '' 203 | # }, 204 | # 'network': 'TESTNET', 205 | # 'horizon': { 206 | # 'uri': '', 207 | # 'online': True, 208 | # 'error': None 209 | # }, 210 | # 'address': '', 211 | # 'transport': { 212 | # 'pool_size': 7, 213 | # 'num_retries': 5, 214 | # 'request_timeout': 11, 215 | # 'retry_statuses': [413, 429, 503, 504], 216 | # 'backoff_factor': 0.5 217 | # } 218 | # } 219 | ``` 220 | - `sdk_version` - the version of this SDK. 221 | - `address` - the SDK wallet address. 222 | - `channels`: 223 | - `all` - the number of channels the SDK was configured with. 224 | - `free` - the number of currently free channels. If the number is consistently close to zero, it means the channels 225 | are always busy, and you might consider adding more channels or more servers. 226 | - `kin_asset` - the KIN asset the SDK was configured with. 227 | - `network` - the network the SDK was configured with (PUBLIC/TESTNET/CUSTOM). 228 | - `horizon`: 229 | - `uri` - the endpoint URI of the Horizon server. 230 | - `online` - Horizon online status. 231 | - `error` - Horizon error (when not `online`) . 232 | - `transport`: 233 | - `pool_size` - number of pooled connections to Horizon. 234 | - `num_retries` - number of retries on failed request. 235 | - `request_timeout` - single request timeout. 236 | - `retry_statuses` - a list of statuses to retry on. 237 | - `backoff_factor` - a backoff factor to apply between retry attempts. 238 | 239 | 240 | ## Limitations 241 | 242 | One of the most sensitive points in Stellar is [transaction sequence](https://www.stellar.org/developers/guides/concepts/transactions.html#sequence-number). 243 | In order for a transaction to be submitted successfully, this number should be correct. However, if you have several 244 | SDK instances, each working with the same wallet account or channel accounts, sequence collisions will occur. 245 | Though the SDK makes an effort to retrieve the correct sequence and retry the transaction, this is not a recommended practice. 246 | Instead, we highly recommend to keep only one SDK instance in your application, having unique channel accounts. 247 | Depending on the nature of your application, here are our recommendations: 248 | 249 | 1. You have a simple (command line) script that sends transactions on demand or only once in a while. 250 | In this case, the SDK can be instantiated with only the wallet key, the channel accounts are not necessary. 251 | 252 | 2. You have a single application server that should handle a stream of concurrent transactions. In this case, 253 | you need to make sure that only a single instance of SDK is initialized with multiple channel accounts. 254 | This is an important point, because if you use a standard `gunicorn/Flask` setup for example, gunicorn will spawn 255 | several *worker processes*, each containing your Flask application, each containing your SDK instance, so mutliple 256 | SDK instances will exist, having the same channel accounts. The solution is to use gunicorn *thread workers* instead of 257 | *process workers*, for example run gunicorn with `--threads` switch instead of `--workers` switch, so that only 258 | one Flask application is created, containing a single SDK instance. 259 | 260 | 3. You have a number of load-balanced application servers. Here, each application server should a) have the setup outlined 261 | above, and b) have its own channel accounts. This way, you ensure you will not have any collisions in your transaction 262 | sequences. 263 | 264 | 265 | ## License 266 | The code is currently released under [MIT license](LICENSE). 267 | 268 | 269 | ## Contributing 270 | See [CONTRIBUTING.md](CONTRIBUTING.md) for SDK contributing guidelines. 271 | 272 | -------------------------------------------------------------------------------- /kin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kinecosystem/kin-sdk-python/c09dc321c8970d70016e304ad45868e5d4e6a773/kin.png -------------------------------------------------------------------------------- /kin/__init__.py: -------------------------------------------------------------------------------- 1 | from .sdk import SDK 2 | from .config import * 3 | from .errors import * 4 | from .stellar.horizon_models import AccountData, TransactionData 5 | from .version import __version__ 6 | -------------------------------------------------------------------------------- /kin/config.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -* 2 | 3 | # Copyright (C) 2018 Kin Foundation 4 | 5 | from stellar_base.asset import Asset 6 | 7 | from .version import __version__ 8 | 9 | 10 | KIN_ISSUER_PROD = 'GDVDKQFP665JAO7A2LSHNLQIUNYNAAIGJ6FYJVMG4DT3YJQQJSRBLQDG' # TODO: real address 11 | KIN_ASSET_PROD = Asset('KIN', KIN_ISSUER_PROD) 12 | 13 | KIN_ISSUER_TEST = 'GCKG5WGBIJP74UDNRIRDFGENNIH5Y3KBI5IHREFAJKV4MQXLELT7EX6V' 14 | KIN_ASSET_TEST = Asset('KIN', KIN_ISSUER_TEST) 15 | 16 | # https://www.stellar.org/developers/guides/concepts/fees.html 17 | BASE_RESERVE = 0.5 # in XLM 18 | MIN_ACCOUNT_BALANCE = (2 + 1) * BASE_RESERVE # 1 additional trustline op 19 | 20 | SDK_USER_AGENT = 'kin-stellar-python/{}'.format(__version__) 21 | -------------------------------------------------------------------------------- /kin/errors.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -* 2 | 3 | # Copyright (C) 2018 Kin Foundation 4 | 5 | from requests.exceptions import RequestException 6 | 7 | from .stellar.errors import * 8 | 9 | 10 | # All exceptions should subclass from SdkError in this module. 11 | class SdkError(Exception): 12 | """Base class for all SDK errors.""" 13 | def __init__(self, message=None, error_code=None, extra=None): 14 | super(SdkError, self).__init__(self) 15 | self.message = message or 'unknown error' 16 | self.error_code = error_code 17 | self.extra = dict(extra or ()) 18 | 19 | def __str__(self): 20 | sb = list() 21 | sb.append("\n\tmessage='{}'".format(self.message)) 22 | sb.append("\n\terror_code='{}'".format(self.error_code)) 23 | sb.append("\n\textra data:") 24 | for key in self.extra: 25 | sb.append("\n\t\t{}='{}'".format(key, self.extra[key])) 26 | return ''.join(sb) 27 | 28 | 29 | class ThrottleError(SdkError): 30 | """Service is busy""" 31 | def __init__(self): 32 | super(ThrottleError, self).__init__('service is busy, retry later') 33 | 34 | 35 | class NetworkError(SdkError): 36 | """Network-level errors - connection error, timeout error, etc.""" 37 | def __init__(self, extra=None): 38 | super(NetworkError, self).__init__('network error', None, extra) 39 | 40 | 41 | class RequestError(SdkError): 42 | """Request-related errors - bad request, invalid payload, malformed transaction, etc.""" 43 | def __init__(self, error_code=None, extra=None): 44 | super(RequestError, self).__init__('bad request', error_code, extra) 45 | 46 | 47 | class ServerError(SdkError): 48 | """Server-related errors - rate limit exceeded, server over capacity.""" 49 | def __init__(self, error_code=None, extra=None): 50 | super(ServerError, self).__init__('server error', error_code, extra) 51 | 52 | 53 | class ResourceNotFoundError(SdkError): 54 | """Resource not found on the server.""" 55 | def __init__(self, error_code=None, extra=None): 56 | super(ResourceNotFoundError, self).__init__('resource not found', error_code, extra) 57 | 58 | 59 | class AccountError(SdkError): 60 | """Base class for account-related errors.""" 61 | def __init__(self, address=None, message=None, error_code=None, extra=None): 62 | if address: 63 | extra = dict(extra or ()) 64 | extra.update({'account': address}) 65 | super(AccountError, self).__init__(message, error_code, extra) 66 | 67 | 68 | class AccountNotFoundError(AccountError): 69 | """Operation referenced a nonexistent account.""" 70 | def __init__(self, address=None, error_code=None, extra=None): 71 | super(AccountNotFoundError, self).__init__(address, 'account not found', error_code, extra) 72 | 73 | 74 | class AccountExistsError(AccountError): 75 | """Trying to create an existing account.""" 76 | def __init__(self, address=None, error_code=None, extra=None): 77 | super(AccountExistsError, self).__init__(address, 'account already exists', error_code, extra) 78 | 79 | 80 | class AccountNotActivatedError(AccountError): 81 | """Operation referenced an account that exists but not yet activated.""" 82 | def __init__(self, address=None, error_code=None, extra=None): 83 | super(AccountNotActivatedError, self).__init__(address, 'account not activated', error_code, extra) 84 | 85 | 86 | class LowBalanceError(SdkError): 87 | """Account balance is too low to complete the operation. Refers both to native and asset balance.""" 88 | def __init__(self, error_code=None, extra=None): 89 | super(LowBalanceError, self).__init__('low balance', error_code, extra) 90 | 91 | 92 | class InternalError(SdkError): 93 | """Internal unhandled error. To find out more, check the error code and extra data.""" 94 | def __init__(self, error_code=None, extra=None): 95 | super(InternalError, self).__init__('internal error', error_code, extra) 96 | 97 | 98 | def translate_error(err): 99 | """A high-level error translator.""" 100 | if isinstance(err, RequestException): 101 | return NetworkError({'internal_error': str(err)}) 102 | if isinstance(err, ChannelsBusyError): 103 | return ThrottleError 104 | if isinstance(err, HorizonError): 105 | return translate_horizon_error(err) 106 | return InternalError(None, {'internal_error': str(err)}) 107 | 108 | 109 | def translate_horizon_error(horizon_error): 110 | """Horizon error translator.""" 111 | # query errors 112 | if horizon_error.type == HorizonErrorType.BAD_REQUEST: 113 | return RequestError(horizon_error.type, {'invalid_field': horizon_error.extras.invalid_field}) 114 | if horizon_error.type == HorizonErrorType.NOT_FOUND: 115 | return ResourceNotFoundError(horizon_error.type) 116 | if horizon_error.type == HorizonErrorType.FORBIDDEN \ 117 | or horizon_error.type == HorizonErrorType.NOT_ACCEPTABLE \ 118 | or horizon_error.type == HorizonErrorType.UNSUPPORTED_MEDIA_TYPE \ 119 | or horizon_error.type == HorizonErrorType.NOT_IMPLEMENTED \ 120 | or horizon_error.type == HorizonErrorType.BEFORE_HISTORY \ 121 | or horizon_error.type == HorizonErrorType.STALE_HISTORY: 122 | return RequestError(horizon_error.type) 123 | 124 | # transaction (submit) errors 125 | if horizon_error.type == HorizonErrorType.TRANSACTION_MALFORMED: 126 | return RequestError(horizon_error.type) 127 | if horizon_error.type == HorizonErrorType.TRANSACTION_FAILED: 128 | return translate_transaction_error(horizon_error) 129 | 130 | # server errors 131 | if horizon_error.type == HorizonErrorType.RATE_LIMIT_EXCEEDED \ 132 | or horizon_error.type == HorizonErrorType.SERVER_OVER_CAPACITY \ 133 | or horizon_error.type == HorizonErrorType.TIMEOUT: 134 | return ServerError(horizon_error.type) 135 | if horizon_error.type == HorizonErrorType.INTERNAL_SERVER_ERROR: 136 | return InternalError(horizon_error.type) 137 | 138 | # unknown 139 | return InternalError(horizon_error.type, {'internal_error': 'unknown horizon error'}) 140 | 141 | 142 | def translate_transaction_error(tx_error): 143 | """Transaction error translator.""" 144 | tx_result_code = tx_error.extras.result_codes.transaction 145 | if tx_result_code == TransactionResultCode.TOO_EARLY \ 146 | or tx_result_code == TransactionResultCode.TOO_LATE \ 147 | or tx_result_code == TransactionResultCode.MISSING_OPERATION \ 148 | or tx_result_code == TransactionResultCode.BAD_AUTH \ 149 | or tx_result_code == TransactionResultCode.BAD_AUTH_EXTRA \ 150 | or tx_result_code == TransactionResultCode.BAD_SEQUENCE \ 151 | or tx_result_code == TransactionResultCode.INSUFFICIENT_FEE: 152 | return RequestError(tx_result_code) 153 | if tx_result_code == TransactionResultCode.NO_ACCOUNT: 154 | return AccountNotFoundError(error_code=tx_result_code) 155 | if tx_result_code == TransactionResultCode.INSUFFICIENT_BALANCE: 156 | return LowBalanceError(tx_result_code) 157 | if tx_result_code == TransactionResultCode.FAILED: 158 | return translate_operation_error(tx_error.extras.result_codes.operations) 159 | return InternalError(tx_result_code, {'internal_error': 'unknown transaction error'}) 160 | 161 | 162 | def translate_operation_error(op_result_codes): 163 | """Operation error translator.""" 164 | # NOTE: we currently handle only one operation per transaction! 165 | op_result_code = op_result_codes[0] 166 | if op_result_code == OperationResultCode.BAD_AUTH \ 167 | or op_result_code == CreateAccountResultCode.MALFORMED \ 168 | or op_result_code == PaymentResultCode.NO_ISSUER \ 169 | or op_result_code == PaymentResultCode.LINE_FULL \ 170 | or op_result_code == ChangeTrustResultCode.INVALID_LIMIT: 171 | return RequestError(op_result_code) 172 | if op_result_code == OperationResultCode.NO_ACCOUNT or op_result_code == PaymentResultCode.NO_DESTINATION: 173 | return AccountNotFoundError(error_code=op_result_code) 174 | if op_result_code == CreateAccountResultCode.ACCOUNT_EXISTS: 175 | return AccountExistsError(error_code=op_result_code) 176 | if op_result_code == CreateAccountResultCode.LOW_RESERVE \ 177 | or op_result_code == PaymentResultCode.UNDERFUNDED: 178 | return LowBalanceError(op_result_code) 179 | if op_result_code == PaymentResultCode.SRC_NO_TRUST \ 180 | or op_result_code == PaymentResultCode.NO_TRUST \ 181 | or op_result_code == PaymentResultCode.SRC_NOT_AUTHORIZED \ 182 | or op_result_code == PaymentResultCode.NOT_AUTHORIZED: 183 | return AccountNotActivatedError(error_code=op_result_code) 184 | return InternalError(op_result_code, {'internal_error': 'unknown operation error'}) 185 | -------------------------------------------------------------------------------- /kin/sdk.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -* 2 | 3 | # Copyright (C) 2018 Kin Foundation 4 | 5 | from decimal import Decimal, getcontext 6 | from functools import partial 7 | 8 | from stellar_base.keypair import Keypair 9 | 10 | from .config import * 11 | from .errors import * 12 | from .stellar.channel_manager import ChannelManager 13 | from .stellar.horizon import Horizon, HORIZON_LIVE, HORIZON_TEST 14 | from .stellar.horizon_models import AccountData, TransactionData 15 | from .stellar.utils import * 16 | from .version import __version__ 17 | 18 | import logging 19 | logger = logging.getLogger(__name__) 20 | 21 | getcontext().prec = 7 # IMPORTANT: XLM decimal precision 22 | 23 | 24 | class SDK(object): 25 | """ 26 | The :class:`kin.SDK` class is the primary interface to the KIN Python SDK based on Stellar Blockchain. 27 | It maintains a connection context with a Horizon node and hides all the specifics of dealing with Stellar REST API. 28 | """ 29 | 30 | def __init__(self, secret_key='', horizon_endpoint_uri='', network='PUBLIC', 31 | channel_secret_keys=None, kin_asset=None): 32 | """Create a new instance of the KIN SDK for Stellar. 33 | 34 | If secret key is not provided, the SDK can still be used in "anonymous" mode with only the following 35 | functions available: 36 | - get_account_native_balance 37 | - get_account_kin_balance 38 | - check_account_exists 39 | - check_account_activated 40 | - get_account_data 41 | - get_transaction_data 42 | - monitor_accounts_kin_payments 43 | - monitor_accounts_transactions 44 | 45 | :param str secret_key: (optional) a key to initialize the sdk wallet account with. If not provided, the wallet 46 | not not be initialized and methods needing the wallet will raise exception. 47 | 48 | :param str horizon_endpoint_uri: (optional) a Horizon endpoint. If not provided, a default global endpoint will 49 | be used, either a `TESTNET` or `PUBLIC`, depending on the `network` parameter. 50 | 51 | :param str network: (optional) either `PUBLIC` or `TESTNET`, will set the Horizon endpoint in the absence of 52 | `horizon_endpoint_uri`. Defaults to `PUBLIC` if not specified. 53 | 54 | :param list of str channel_secret_keys: (optional) a list of channels to sign transactions with. More channels 55 | means less blocking on transactions and better response time. 56 | 57 | :param kin_asset: the KIN asset to work with. *For testing purposes only*. 58 | :type: :class:`stellar_base.asset.Asset` 59 | 60 | :return: An instance of the SDK. 61 | :rtype: :class:`kin.SDK` 62 | 63 | :raises: ValueError: if some of the configuration parameters are invalid. 64 | :raises: :class:`kin.AccountNotFoundError`: if SDK wallet or channel account is not yet created. 65 | :raises: :class:`kin.AccountNotActivatedError`: if SDK wallet account is not yet activated. 66 | :raises: :class:`kin.NetworkError`: if there is a problem connecting to Horizon. 67 | """ 68 | 69 | channel_secret_keys = channel_secret_keys or [] 70 | self.network = network or 'PUBLIC' 71 | 72 | # init our asset 73 | if kin_asset: 74 | self.kin_asset = kin_asset 75 | else: 76 | self.kin_asset = KIN_ASSET_PROD if self.network == 'PUBLIC' else KIN_ASSET_TEST 77 | 78 | # set connection pool size for channels + monitoring connection + extra 79 | pool_size = max(1, len(channel_secret_keys)) + 2 80 | 81 | if horizon_endpoint_uri: 82 | self.horizon = Horizon(horizon_uri=horizon_endpoint_uri, pool_size=pool_size, user_agent=SDK_USER_AGENT) 83 | else: 84 | if self.network == 'TESTNET': 85 | self.horizon = Horizon(horizon_uri=HORIZON_TEST, pool_size=pool_size, user_agent=SDK_USER_AGENT) 86 | else: 87 | self.horizon = Horizon(horizon_uri=HORIZON_LIVE, pool_size=pool_size, user_agent=SDK_USER_AGENT) 88 | 89 | # init sdk wallet account if a secret key is supplied 90 | self.base_keypair = None 91 | if secret_key: 92 | # check wallet key 93 | if not is_valid_secret_key(secret_key): 94 | raise ValueError('invalid secret key: {}'.format(secret_key)) 95 | 96 | # check channel keys 97 | if channel_secret_keys: 98 | for channel_key in channel_secret_keys: 99 | if not is_valid_secret_key(channel_key): 100 | raise ValueError('invalid channel key: {}'.format(channel_key)) 101 | 102 | self.base_keypair = Keypair.from_seed(secret_key) 103 | self.base_address = self.base_keypair.address().decode() 104 | 105 | # check that sdk wallet account exists and is activated 106 | self._get_account_asset_balance(self.base_address, self.kin_asset) 107 | 108 | # check that channel accounts exist (they do not have to be activated) 109 | if channel_secret_keys: 110 | for channel_key in channel_secret_keys: 111 | channel_address = Keypair.from_seed(channel_key).address().decode() 112 | self.get_account_data(channel_address) 113 | else: 114 | channel_secret_keys = [secret_key] 115 | 116 | # init channel manager 117 | self.channel_manager = ChannelManager(secret_key, channel_secret_keys, self.network, self.horizon) 118 | 119 | logger.info('Kin SDK inited on network {}, horizon endpoint {}'.format(self.network, self.horizon.horizon_uri)) 120 | 121 | def get_status(self): 122 | """Get system configuration data and online status.""" 123 | status = { 124 | 'sdk_version': __version__, 125 | 'network': self.network, 126 | 'address': None, 127 | 'kin_asset': { 128 | 'code': self.kin_asset.code, 129 | 'issuer': self.kin_asset.issuer 130 | }, 131 | 'horizon': { 132 | 'uri': self.horizon.horizon_uri, 133 | 'online': False, 134 | 'error': None, 135 | }, 136 | 'transport': { 137 | 'pool_size': self.horizon.pool_size, 138 | 'num_retries': self.horizon.num_retries, 139 | 'request_timeout': self.horizon.request_timeout, 140 | 'retry_statuses': self.horizon.status_forcelist, 141 | 'backoff_factor': self.horizon.backoff_factor, 142 | }, 143 | 'channels': None, 144 | } 145 | if self.base_keypair: 146 | status['address'] = self.get_address() 147 | status['channels'] = { 148 | 'all': self.channel_manager.num_channels, 149 | 'free': self.channel_manager.channel_builders.qsize() 150 | } 151 | 152 | # now check Horizon connection 153 | try: 154 | self.horizon.query('') 155 | status['horizon']['online'] = True 156 | except Exception as e: 157 | status['horizon']['error'] = str(e) 158 | 159 | return status 160 | 161 | def get_address(self): 162 | """Get the address of the SDK wallet account. 163 | The wallet is configured by a secret key supplied during SDK initialization. 164 | 165 | :return: public address of the wallet. 166 | :rtype: str 167 | 168 | :raises: :class:`kin.SdkError`: if the SDK wallet is not configured. 169 | """ 170 | if not self.base_keypair: 171 | raise SdkError('address not configured') 172 | return self.base_address 173 | 174 | def get_native_balance(self): 175 | """Get native (lumen) balance of the SDK wallet. 176 | The wallet is configured by a secret key supplied during SDK initialization. 177 | 178 | :return: : the balance in lumens. 179 | :rtype: Decimal 180 | 181 | :raises: :class:`kin.SdkError`: if the SDK wallet is not configured. 182 | """ 183 | return self.get_account_native_balance(self.get_address()) 184 | 185 | def get_kin_balance(self): 186 | """Get KIN balance of the SDK wallet. 187 | The wallet is configured by a secret key supplied during SDK initialization. 188 | 189 | :return: the balance in KIN. 190 | :rtype: Decimal 191 | 192 | :raises: :class:`kin.SdkError`: if the SDK wallet is not configured. 193 | """ 194 | return self.get_account_kin_balance(self.get_address()) 195 | 196 | def get_account_native_balance(self, address): 197 | """Get native (lumen) balance of the account identified by the provided address. 198 | 199 | :param: str address: the address of the account to query. 200 | 201 | :return: the lumen balance of the account. 202 | :rtype: Decimal 203 | 204 | :raises: ValueError: if the supplied address has a wrong format. 205 | :raises: :class:`kin.AccountNotFoundError`: if the account does not exist. 206 | """ 207 | return self._get_account_asset_balance(address, Asset.native()) 208 | 209 | def get_account_kin_balance(self, address): 210 | """Get KIN balance of the account identified by the provided address. 211 | 212 | :param str address: the address of the account to query. 213 | 214 | :return: : the balance in KIN of the account. 215 | :rtype: Decimal 216 | 217 | :raises: ValueError: if the supplied address has a wrong format. 218 | :raises: :class:`kin.AccountNotFoundError`: if the account does not exist. 219 | :raises: :class:`kin.AccountNotActivatedError`: if the account is not activated. 220 | """ 221 | return self._get_account_asset_balance(address, self.kin_asset) 222 | 223 | def create_account(self, address, starting_balance=MIN_ACCOUNT_BALANCE, memo_text=None, activate=False): 224 | """Create an account identified by the provided address. 225 | 226 | :param str address: the address of the account to create. 227 | 228 | :param number starting_balance: (optional) the starting balance of the account. If not provided, a default 229 | MIN_ACCOUNT_BALANCE will be used. 230 | 231 | :param str memo_text: (optional) a text to put into transaction memo. 232 | 233 | :param boolean activate: (optional) should the created account be activated 234 | 235 | :return: transaction hash 236 | :rtype: str 237 | 238 | :raises: :class:`kin.SdkError` if the SDK wallet is not configured. 239 | :raises: ValueError: if the supplied address has a wrong format. 240 | :raises: :class:`kin.AccountExistsError`: if the account already exists. 241 | """ 242 | if not self.base_keypair: 243 | raise SdkError('address not configured') 244 | 245 | if not is_valid_address(address): 246 | raise ValueError('invalid address: {}'.format(address)) 247 | 248 | try: 249 | pretrusted_asset = self.kin_asset if activate else None 250 | reply = self.channel_manager.send_transaction(lambda builder: 251 | partial(builder.append_create_account_op, address, 252 | starting_balance, pretrusted_asset=pretrusted_asset), 253 | memo_text=memo_text) 254 | return reply['hash'] 255 | except Exception as e: 256 | raise translate_error(e) 257 | 258 | def check_account_exists(self, address): 259 | """Check whether the account identified by the provided address exists. 260 | 261 | :param str address: the account address to query. 262 | 263 | :return: True if the account exists. 264 | :rtype: boolean 265 | 266 | :raises: ValueError: if the supplied address has a wrong format. 267 | """ 268 | try: 269 | self.get_account_data(address) 270 | return True 271 | except AccountNotFoundError: 272 | return False 273 | 274 | def check_account_activated(self, address): 275 | """Check if the account is activated (has a trustline to KIN asset). 276 | 277 | :param str address: the account address to query. 278 | 279 | :return: True if the account is activated. 280 | :rtype: boolean 281 | 282 | :raises: ValueError: if the supplied address has a wrong format. 283 | :raises: :class:`kin.AccountNotFoundError`: if the account is not yet created. 284 | """ 285 | return self._check_asset_trusted(address, self.kin_asset) 286 | 287 | def send_native(self, address, amount, memo_text=None): 288 | """Send native currency (lumens) to the account identified by the provided address. 289 | 290 | :param str address: the account to send lumens to. 291 | 292 | :param number amount: the number of lumens to send. 293 | 294 | :param str memo_text: (optional) a text to put into transaction memo. 295 | 296 | :return: transaction hash 297 | :rtype: str 298 | 299 | :raises: :class:`kin.SdkError` if the SDK wallet is not configured. 300 | :raises: ValueError: if the provided address has a wrong format. 301 | :raises: ValueError: if the amount is not positive. 302 | :raises: :class:`kin.AccountNotFoundError`: if the account does not exist. 303 | :raises: :class:`kin.LowBalanceError`: if there is not enough money to send and pay transaction fee. 304 | """ 305 | return self._send_asset(Asset.native(), address, amount, memo_text) 306 | 307 | def send_kin(self, address, amount, memo_text=None): 308 | """Send KIN to the account identified by the provided address. 309 | 310 | :param str address: the account to send KIN to. 311 | 312 | :param number amount: the amount of KIN to send. 313 | 314 | :param str memo_text: (optional) a text to put into transaction memo. 315 | 316 | :return: transaction hash 317 | :rtype: str 318 | 319 | :raises: :class:`kin.SdkError` if the SDK wallet is not configured. 320 | :raises: ValueError: if the provided address has a wrong format. 321 | :raises: ValueError: if the amount is not positive. 322 | :raises: :class:`kin.AccountNotFoundError`: if the account does not exist. 323 | :raises: :class:`kin.AccountNotActivatedError`: if the account is not activated. 324 | :raises: :class:`kin.LowBalanceError`: if there is not enough money to send and pay transaction fee. 325 | """ 326 | return self._send_asset(self.kin_asset, address, amount, memo_text) 327 | 328 | def get_account_data(self, address): 329 | """Gets account data. 330 | 331 | :param str address: the account to query. 332 | 333 | :return: account data 334 | :rtype: :class:`kin.AccountData` 335 | 336 | :raises: ValueError: if the provided address has a wrong format. 337 | :raises: :class:`kin.AccountNotFoundError`: if the account does not exist. 338 | """ 339 | if not is_valid_address(address): 340 | raise ValueError('invalid address: {}'.format(address)) 341 | 342 | try: 343 | acc = self.horizon.account(address) 344 | return AccountData(acc, strict=False) 345 | except Exception as e: 346 | err = translate_error(e) 347 | raise AccountNotFoundError(address) if isinstance(err, ResourceNotFoundError) else err 348 | 349 | def get_transaction_data(self, tx_hash): 350 | """Gets transaction data. 351 | 352 | :param str tx_hash: transaction hash. 353 | 354 | :return: transaction data 355 | :rtype: :class:`kin.TransactionData` 356 | 357 | :raises: ValueError: if the provided hash is invalid. 358 | :raises: :class:`kin.ResourceNotFoundError`: if the transaction does not exist. 359 | """ 360 | if not is_valid_transaction_hash(tx_hash): 361 | raise ValueError('invalid transaction hash: {}'.format(tx_hash)) 362 | 363 | try: 364 | tx = self.horizon.transaction(tx_hash) 365 | 366 | # get transaction operations 367 | tx_ops = self.horizon.transaction_operations(tx['hash'], params={'limit': 100}) 368 | tx['operations'] = tx_ops['_embedded']['records'] 369 | 370 | return TransactionData(tx, strict=False) 371 | except Exception as e: 372 | raise translate_error(e) 373 | 374 | def monitor_kin_payments(self, callback_fn): 375 | """Monitor KIN payment transactions related to the SDK wallet account. 376 | NOTE: the function starts a background thread. 377 | 378 | :param callback_fn: the function to call on each received payment as `callback_fn(address, tx_data)`. 379 | :type: callable[[str, :class:`kin.TransactionData`], None] 380 | 381 | :raises: :class:`kin.SdkError` if the SDK wallet is not configured. 382 | """ 383 | self.monitor_accounts_kin_payments([self.get_address()], callback_fn) 384 | 385 | def monitor_accounts_kin_payments(self, addresses, callback_fn): 386 | """Monitor KIN payment transactions related to the accounts identified by provided addresses. 387 | NOTE: the function starts a background thread. 388 | 389 | :param list of str addresses: the addresses of the accounts to query. 390 | 391 | :param callback_fn: the function to call on each received payment as `callback_fn(address, tx_data)`. 392 | :type: callable[[str, :class:`kin.TransactionData`], None] 393 | 394 | :raises: ValueError: when no addresses are given. 395 | :raises: ValueError: if one of the provided addresses has a wrong format. 396 | :raises: :class:`kin.AccountNotFoundError`: if one of the provided accounts is not yet created. 397 | """ 398 | self._monitor_accounts_asset_transactions(self.kin_asset, addresses, callback_fn, only_payments=True) 399 | 400 | # noinspection PyTypeChecker 401 | def monitor_accounts_transactions(self, addresses, callback_fn): 402 | """Monitor transactions related to the account identified by a provided addresses (all transaction types). 403 | NOTE: the function starts a background thread. 404 | 405 | :param list of str addresses: the addresses of the accounts to query. 406 | 407 | :param callback_fn: the function to call on each received transaction as `callback_fn(address, tx_data)`. 408 | :type: callable[[str, :class:`kin.TransactionData`], None] 409 | 410 | :raises: ValueError: when no addresses are given. 411 | :raises: ValueError: if one of the provided addresses has a wrong format. 412 | :raises: :class:`kin.AccountNotFoundError`: if one of the provided accounts is not yet created. 413 | """ 414 | self._monitor_accounts_asset_transactions(None, addresses, callback_fn) 415 | 416 | # Helpers 417 | 418 | def _get_account_asset_balance(self, address, asset): 419 | """Get asset balance of the account identified by the provided address. 420 | 421 | :param str address: the address of the account to query. 422 | 423 | :param asset: the asset to get balance for. 424 | :type: :class:`stellar_base.asset.Asset` 425 | 426 | :return: : the balance in asset units of the account. 427 | :rtype: Decimal 428 | 429 | :raises: ValueError: if the supplied address has a wrong format. 430 | :raises: ValueError: when account is not activated (no trustline). 431 | :raises: :class:`kin.AccountNotFoundError`: if the account does not exist. 432 | :raises: :class:`kin.AccountNotActivatedError`: if the account is not activated for the asset. 433 | """ 434 | if not asset.is_native() and not is_valid_address(asset.issuer): 435 | raise ValueError('invalid asset issuer: {}'.format(asset.issuer)) 436 | 437 | acc_data = self.get_account_data(address) 438 | 439 | for balance in acc_data.balances: 440 | if (balance.asset_type == 'native' and asset.code == 'XLM') \ 441 | or (balance.asset_code == asset.code and balance.asset_issuer == asset.issuer): 442 | return balance.balance 443 | 444 | raise AccountNotActivatedError(address) 445 | 446 | def _trust_asset(self, asset, limit=None, memo_text=None): 447 | """Establish a trustline from the SDK wallet to the asset issuer. 448 | 449 | :param asset: the asset to establish a trustline to. 450 | :type: :class:`stellar_base.asset.Asset` 451 | 452 | :param number limit: trustline limit. 453 | 454 | :param str memo_text: (optional) a text to put into transaction memo. 455 | 456 | :return: transaction hash 457 | :rtype: str 458 | 459 | :raises: :class:`kin.SdkError` if the SDK wallet is not configured. 460 | :raises: ValueError: if the issuer address has a wrong format. 461 | :raises: :class:`kin.LowBalanceError`: if there is not enough money to pay transaction fee. 462 | """ 463 | if not self.base_keypair: 464 | raise SdkError('address not configured') 465 | 466 | if not asset.is_native() and not is_valid_address(asset.issuer): 467 | raise ValueError('invalid asset issuer: {}'.format(asset.issuer)) 468 | 469 | try: 470 | reply = self.channel_manager.send_transaction(lambda builder: 471 | partial(builder.append_trust_op, asset.issuer, asset.code, 472 | limit=limit), 473 | memo_text=memo_text) 474 | return reply['hash'] 475 | except Exception as e: 476 | raise translate_error(e) 477 | 478 | def _check_asset_trusted(self, address, asset): 479 | """Check if the account has a trustline to the provided asset. 480 | 481 | :param str address: the account address to query. 482 | 483 | :param asset: the asset to check 484 | :type: :class:`stellar_base.asset.Asset` 485 | 486 | :return: True if the account has a trustline to the asset. 487 | :rtype: boolean 488 | 489 | :raises: ValueError: if the supplied address has a wrong format. 490 | :raises: ValueError: if the asset issuer address has a wrong format. 491 | :raises: :class:`kin.AccountNotFoundError`: if the account does not exist. 492 | """ 493 | try: 494 | self._get_account_asset_balance(address, asset) 495 | return True 496 | except AccountNotActivatedError: 497 | return False 498 | 499 | def _send_asset(self, asset, address, amount, memo_text=None): 500 | """Send asset to the account identified by the provided address. 501 | 502 | :param str address: the account to send asset to. 503 | 504 | :param asset: asset to send 505 | :type: :class:`stellar_base.asset.Asset` 506 | 507 | :param number amount: the asset amount to send. 508 | 509 | :param str memo_text: (optional) a text to put into transaction memo. 510 | 511 | :return: transaction hash 512 | :rtype: str 513 | 514 | :raises: :class:`kin.SdkError` if the SDK wallet is not configured. 515 | :raises: ValueError: if the provided address has a wrong format. 516 | :raises: ValueError: if the asset issuer address has a wrong format. 517 | :raises: ValueError: if the amount is not positive. 518 | :raises: :class:`kin.AccountNotFoundError`: if the account does not exist. 519 | :raises: :class:`kin.AccountNotActivatedError`: if the account is not activated for the asset. 520 | :raises: :class:`kin.LowBalanceError`: if there is not enough money to send and pay transaction fee. 521 | """ 522 | if not self.base_keypair: 523 | raise SdkError('address not configured') 524 | 525 | if not is_valid_address(address): 526 | raise ValueError('invalid address: {}'.format(address)) 527 | 528 | if amount <= 0: 529 | raise ValueError('amount must be positive') 530 | 531 | if not asset.is_native() and not is_valid_address(asset.issuer): 532 | raise ValueError('invalid asset issuer: {}'.format(asset.issuer)) 533 | 534 | try: 535 | reply = self.channel_manager.send_transaction(lambda builder: 536 | partial(builder.append_payment_op, address, amount, 537 | asset_type=asset.code, asset_issuer=asset.issuer), 538 | memo_text=memo_text) 539 | return reply['hash'] 540 | except Exception as e: 541 | raise translate_error(e) 542 | 543 | def _monitor_accounts_asset_transactions(self, asset, addresses, callback_fn, only_payments=False): 544 | """Monitor transactions related to the accounts identified by provided addresses. If asset is given, only 545 | the transactions for this asset will be returned. 546 | NOTE: the functions starts a background thread. 547 | 548 | :param asset: (optional) the asset to query. 549 | :type: :class:`stellar_base.asset.Asset` 550 | 551 | :param list of str addresses: the list of account addresses to query. 552 | 553 | :param callback_fn: the function to call on each received transaction as `callback_fn(address, tx_data)`. 554 | :type: callable[[str, :class:`kin.TransactionData`], None] 555 | 556 | :param boolean only_payments: whether to return payment transactions only. 557 | 558 | :raises: ValueError: if asset issuer is invalid. 559 | :raises: ValueError: when no addresses are given. 560 | :raises: ValueError: if one of the provided addresses has a wrong format. 561 | :raises: :class:`kin.AccountNotFoundError`: if one of the provided accounts is not yet created. 562 | """ 563 | if asset and not asset.is_native() and not is_valid_address(asset.issuer): 564 | raise ValueError('invalid asset issuer: {}'.format(asset.issuer)) 565 | 566 | if not addresses: 567 | raise ValueError('no addresses to monitor') 568 | 569 | for address in addresses: 570 | if not is_valid_address(address): 571 | raise ValueError('invalid address: {}'.format(address)) 572 | 573 | for address in addresses: 574 | if not self.check_account_exists(address): 575 | raise AccountNotFoundError(addresses) 576 | 577 | # Currently, due to nonstandard SSE implementation in Horizon, using cursor=now will hang. 578 | # Instead, we determine the cursor ourselves. 579 | params = {} 580 | if len(addresses) == 1: 581 | reply = self.horizon.account_transactions(addresses[0], params={'order': 'desc', 'limit': 2}) 582 | else: 583 | reply = self.horizon.transactions(params={'order': 'desc', 'limit': 2}) 584 | 585 | if len(reply['_embedded']['records']) == 2: 586 | cursor = TransactionData(reply['_embedded']['records'][1], strict=False).paging_token 587 | params = {'cursor': cursor} 588 | 589 | # make synchronous SSE request (will raise errors in the current thread) 590 | if len(addresses) == 1: 591 | events = self.horizon.account_transactions(addresses[0], sse=True, params=params) 592 | else: 593 | events = self.horizon.transactions(sse=True, params=params) 594 | 595 | # asynchronous event processor 596 | def event_processor(): 597 | import json 598 | for event in events: 599 | if event.event != 'message': 600 | continue 601 | try: 602 | tx = json.loads(event.data) 603 | 604 | # get transaction operations 605 | tx_ops = self.horizon.transaction_operations(tx['hash'], params={'limit': 100}) 606 | tx['operations'] = tx_ops['_embedded']['records'] 607 | 608 | # deserialize 609 | tx_data = TransactionData(tx, strict=False) 610 | 611 | # iterate over transaction operations and see if there's a match 612 | for op_data in tx_data.operations: 613 | if only_payments and op_data.type != 'payment': 614 | continue 615 | if asset: 616 | if op_data.asset_type == 'native' and not asset.is_native(): 617 | continue 618 | if op_data.asset_code != asset.code or op_data.asset_issuer != asset.issuer: 619 | continue 620 | if len(addresses) == 1: 621 | callback_fn(addresses[0], tx_data) 622 | break 623 | elif op_data.from_address in addresses: 624 | callback_fn(op_data.from_address, tx_data) 625 | break 626 | elif op_data.to_address in addresses: 627 | callback_fn(op_data.to_address, tx_data) 628 | break 629 | 630 | except Exception as ex: 631 | logger.exception(ex) 632 | continue 633 | 634 | # start monitoring thread 635 | import threading 636 | t = threading.Thread(target=event_processor) 637 | t.daemon = True 638 | t.start() 639 | -------------------------------------------------------------------------------- /kin/stellar/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kinecosystem/kin-sdk-python/c09dc321c8970d70016e304ad45868e5d4e6a773/kin/stellar/__init__.py -------------------------------------------------------------------------------- /kin/stellar/builder.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -* 2 | 3 | # Copyright (C) 2018 Kin Foundation 4 | 5 | from stellar_base.builder import Builder as BaseBuilder 6 | from stellar_base.keypair import Keypair 7 | from stellar_base.memo import NoneMemo 8 | 9 | from .horizon import HORIZON_LIVE, HORIZON_TEST 10 | from .horizon import Horizon 11 | from .utils import is_valid_address, is_valid_secret_key 12 | 13 | 14 | class Builder(BaseBuilder): 15 | """ 16 | This class overrides :class:`stellar_base.builder` to provide additional functionality. 17 | """ 18 | def __init__(self, secret=None, address=None, horizon=None, horizon_uri=None, network=None): 19 | if secret: 20 | if not is_valid_secret_key(secret): 21 | raise ValueError('invalid secret key') 22 | address = Keypair.from_seed(secret).address().decode() 23 | elif address: 24 | if not is_valid_address(address): 25 | raise ValueError('invalid address') 26 | else: 27 | raise Exception('either secret or address must be provided') 28 | 29 | # call baseclass constructor to init base class variables 30 | super(Builder, self).__init__(secret=secret, address=address, sequence=1) 31 | 32 | # custom overrides 33 | 34 | self.network = network.upper() if network else 'PUBLIC' 35 | 36 | if horizon: 37 | self.horizon = horizon 38 | elif horizon_uri: 39 | self.horizon = Horizon(horizon_uri) 40 | else: 41 | self.horizon = Horizon(HORIZON_LIVE) if self.network == 'PUBLIC' else Horizon(HORIZON_TEST) 42 | 43 | def clear(self): 44 | """"Clears the builder so it can be reused.""" 45 | self.ops = [] 46 | self.time_bounds = [] 47 | self.memo = NoneMemo() 48 | self.fee = None 49 | self.tx = None 50 | self.te = None 51 | 52 | def get_sequence(self): 53 | """Alternative implementation to expose exceptions""" 54 | return self.horizon.account(self.address).get('sequence') 55 | 56 | def next(self): 57 | """ 58 | Alternative implementation that does not create a new builder but clears the current one and increments 59 | the account sequence number. 60 | """ 61 | self.clear() 62 | self.sequence = str(int(self.sequence) + 1) 63 | 64 | def sign(self, secret=None): 65 | """ 66 | Alternative implementation that does not use the self-managed sequence, but always fetches it from Horizon. 67 | """ 68 | if not secret: # only get the new sequence for my own account 69 | self.sequence = self.get_sequence() 70 | super(Builder, self).sign(secret) 71 | 72 | def append_create_account_op(self, destination, starting_balance, source=None, pretrusted_asset=None): 73 | """ 74 | Alternative implementation that allows to create a trustline in addition to the create account operation 75 | Needs to be supported by the blockchain 76 | """ 77 | super(Builder, self).append_create_account_op(destination, starting_balance,source) 78 | if pretrusted_asset: 79 | # Source for the trust op should be the created account 80 | super(Builder, self).append_trust_op(pretrusted_asset.issuer, pretrusted_asset.code, source=destination) 81 | -------------------------------------------------------------------------------- /kin/stellar/channel_manager.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -* 2 | 3 | # Copyright (C) 2018 Kin Foundation 4 | 5 | import sys 6 | from time import sleep 7 | 8 | from stellar_base.keypair import Keypair 9 | 10 | from .builder import Builder 11 | from .errors import ChannelsBusyError, HorizonError, HorizonErrorType, TransactionResultCode 12 | 13 | import logging 14 | logger = logging.getLogger(__name__) 15 | 16 | if sys.version[0] == '2': 17 | import Queue as queue 18 | else: 19 | # noinspection PyUnresolvedReferences 20 | import queue as queue 21 | 22 | CHANNEL_QUEUE_TIMEOUT = 11 # how much time to wait until a channel is available, in seconds 23 | 24 | 25 | class ChannelManager(object): 26 | """ The class :class:`kin.ChannelManager` wraps channel-related specifics of transaction sending.""" 27 | def __init__(self, secret_key, channel_keys, network, horizon): 28 | self.base_key = secret_key 29 | self.base_address = Keypair.from_seed(secret_key).address().decode() 30 | self.num_channels = len(channel_keys) 31 | self.channel_builders = queue.Queue(len(channel_keys)) 32 | self.horizon = horizon 33 | for channel_key in channel_keys: 34 | # create a channel transaction builder. 35 | builder = Builder(secret=channel_key, network=network, horizon=horizon) 36 | self.channel_builders.put(builder) 37 | 38 | def send_transaction(self, add_ops_fn, memo_text=None): 39 | """Send a transaction using an available channel account. 40 | 41 | :param add_ops_fn: a function to call, that will add operations to the transaction. The function should be 42 | `partial`, because a `source` parameter will be added. 43 | :type add_ops_fn: callable[builder] 44 | 45 | :param str memo_text: (optional) a text to add as transaction memo. 46 | 47 | :return: transaction object 48 | :rtype: dict 49 | """ 50 | # send and retry bad sequence errors 51 | retry_count = self.horizon.num_retries 52 | while True: 53 | # get an available channel builder first (blocking with timeout) 54 | try: 55 | builder = self.channel_builders.get(True, CHANNEL_QUEUE_TIMEOUT) 56 | except queue.Empty: 57 | raise ChannelsBusyError 58 | 59 | retrying = False 60 | try: 61 | # operation source is always the base account 62 | source = self.base_address if builder.address != self.base_address else None 63 | 64 | # add operation (using external partial) and sign 65 | add_ops_fn(builder)(source=source) 66 | if memo_text: 67 | builder.add_text_memo(memo_text[:28]) # max memo length is 28 68 | 69 | builder.sign() # always sign with a channel key 70 | if source: 71 | builder.sign(secret=self.base_key) # sign with the base key if needed 72 | return builder.submit() 73 | except HorizonError as e: 74 | logging.warning('send transaction error with channel {}: {}'.format(builder.address, str(e))) 75 | # retry bad sequence error 76 | if e.type == HorizonErrorType.TRANSACTION_FAILED \ 77 | and e.extras.result_codes.transaction == TransactionResultCode.BAD_SEQUENCE \ 78 | and retry_count > 0: 79 | retrying = True 80 | retry_count -= 1 81 | logging.warning('send transaction retry attempt {}'.format(retry_count)) 82 | continue 83 | raise 84 | finally: 85 | # always clean the builder and return it to the queue 86 | builder.clear() 87 | self.channel_builders.put(builder) 88 | if retrying: 89 | sleep(builder.horizon.backoff_factor) 90 | -------------------------------------------------------------------------------- /kin/stellar/errors.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -* 2 | 3 | # Copyright (C) 2018 Kin Foundation 4 | 5 | from .horizon_models import HTTPProblemDetails 6 | 7 | 8 | class ChannelsBusyError(Exception): 9 | pass 10 | 11 | 12 | HORIZON_NS_PREFIX = 'https://stellar.org/horizon-errors/' 13 | 14 | ''' 15 | Horizon error example: 16 | 17 | { 18 | 'status': 400, 19 | 'title': 'Transaction Failed', 20 | 'detail': 'The transaction failed when submitted to the stellar network. The `extras.result_codes` field on this ' 21 | 'response contains further details. Descriptions of each code can be found at: ' 22 | 'https://www.stellar.org/developers/learn/concepts/list-of-operations.html', 23 | 'instance': '903d29404b0e/DAurVuBoL4-004368', 24 | 'extras': { 25 | 'result_codes': { 26 | 'operations': ['op_no_destination'], 27 | 'transaction': 'tx_failed' 28 | }, 29 | 'envelope_xdr': u'AAAAAJgXswhWU+pdHmHIurQuHk4ziNlKFxEJltbMOpF6EqETAAAAZAAAed0AAAAQAAAAAAAAAAAAAAABAAAAAAAAA' 30 | u'AEAAAAAxbIcFBPzPZbzjWdkSB5FCSIva+WdQ2Oi70GUmFvFmOcAAAABVEVTVAAAAAD284i665ald1Kiq064FGlL+' 31 | u'Aeych/b9UQngBHR37ZeiwAAAAAF9eEAAAAAAAAAAAF6EqETAAAAQN5x3xaOaeDS5EF3tE0X9zXymhqkOg95Tyfgu' 32 | u'//TCbv9XN49CHoH5K+BUH04o1ZAZdHbnBABxh44bu7zbFLgQQU=', 33 | 'invalid_field': None, 34 | 'result_xdr': u'AAAAAAAAAGT/////AAAAAQAAAAAAAAAB////+wAAAAA=' 35 | }, 36 | 'type': 'https://stellar.org/horizon-errors/transaction_failed' 37 | } 38 | ''' 39 | 40 | 41 | class HorizonError(HTTPProblemDetails, Exception): 42 | def __init__(self, err_dict): 43 | super(HTTPProblemDetails, self).__init__(err_dict, strict=False) 44 | super(Exception, self).__init__(self.title) 45 | if len(self.type) > len(HORIZON_NS_PREFIX): 46 | self.type = self.type[len(HORIZON_NS_PREFIX):] 47 | 48 | 49 | # noinspection PyClassHasNoInit 50 | class HorizonErrorType: 51 | BAD_REQUEST = 'bad_request' # cannot understand the request due to invalid parameters 52 | BEFORE_HISTORY = 'before_history' # outside the range of recorded history 53 | FORBIDDEN = 'forbidden' # not authorized to see 54 | NOT_ACCEPTABLE = 'not_acceptable' # cannot reply with the requested data format 55 | NOT_FOUND = 'not_found' # resource not found 56 | NOT_IMPLEMENTED = 'not_implemented' # request method is not supported 57 | RATE_LIMIT_EXCEEDED = 'rate_limit_exceeded' # too many requests in a one hour time frame 58 | SERVER_OVER_CAPACITY = 'server_over_capacity' # server is currently overloaded 59 | STALE_HISTORY = 'stale_history' # historical request out of date than the configured threshold 60 | TIMEOUT = 'timeout' # request timed out before completing 61 | TRANSACTION_MALFORMED = 'transaction_malformed' 62 | TRANSACTION_FAILED = 'transaction_failed' # transaction well-formed but failed 63 | UNSUPPORTED_MEDIA_TYPE = 'unsupported_media_type' # unsupported content type 64 | INTERNAL_SERVER_ERROR = 'server_error' 65 | 66 | 67 | # references: 68 | # - github.com/stellar/go/blob/master/xdr/xdr_generated.go 69 | # - github.com/stellar/go/blob/master/services/horizon/internal/actions_transaction.go 70 | # - github.com/stellar/go/blob/master/services/horizon/internal/render/problem/main.go 71 | # - github.com/stellar/horizon/blob/master/src/github.com/stellar/horizon/codes/main.go 72 | 73 | # noinspection PyClassHasNoInit 74 | class TransactionResultCode: 75 | SUCCESS = 'tx_success' # all operations succeeded 76 | FAILED = 'tx_failed' # one of the operations failed (none were applied) 77 | TOO_EARLY = 'tx_too_early' # ledger closeTime before minTime 78 | TOO_LATE = 'tx_too_late' # ledger closeTime after maxTime 79 | MISSING_OPERATION = 'tx_missing_operation' # no operation was specified 80 | BAD_SEQUENCE = 'tx_bad_seq' # sequence number does not match source account 81 | BAD_AUTH = 'tx_bad_auth' # too few valid signatures / wrong network 82 | INSUFFICIENT_BALANCE = 'tx_insufficient_balance' # fee would bring account below reserve 83 | NO_ACCOUNT = 'tx_no_source_account' # source account not found 84 | INSUFFICIENT_FEE = 'tx_insufficient_fee' # fee is too small 85 | BAD_AUTH_EXTRA = 'tx_bad_auth_extra' # unused signatures attached to transaction 86 | INTERNAL_ERROR = 'tx_internal_error' # an unknown error occurred 87 | 88 | 89 | # noinspection PyClassHasNoInit 90 | class OperationResultCode: 91 | INNER = 'op_inner' 92 | BAD_AUTH = 'op_bad_auth' 93 | NO_ACCOUNT = 'op_no_source_account' 94 | NOT_SUPPORTED = 'op_not_supported' 95 | 96 | 97 | # noinspection PyClassHasNoInit 98 | class CreateAccountResultCode: 99 | SUCCESS = 'op_success' # account was created 100 | MALFORMED = 'op_malformed' # invalid destination account 101 | UNDERFUNDED = 'op_underfunded' # not enough funds in source account 102 | LOW_RESERVE = 'op_low_reserve' # would create an account below the min reserve 103 | ACCOUNT_EXISTS = 'op_already_exists' # account already exists 104 | 105 | 106 | # noinspection PyClassHasNoInit 107 | class PaymentResultCode: 108 | SUCCESS = 'op_success' # payment successfully completed 109 | MALFORMED = 'op_malformed' # bad input 110 | UNDERFUNDED = 'op_underfunded' # not enough funds in source account 111 | SRC_NO_TRUST = 'op_src_no_trust' # no trust line on source account 112 | SRC_NOT_AUTHORIZED = 'op_src_not_authorized' # source not authorized to transfer 113 | NO_DESTINATION = 'op_no_destination' # destination account does not exist 114 | NO_TRUST = 'op_no_trust' # destination missing a trust line for asset 115 | NOT_AUTHORIZED = 'op_not_authorized' # destination not authorized to hold asset 116 | LINE_FULL = 'op_line_full' # destination would go above their limit 117 | NO_ISSUER = 'op_no_issuer' # missing issuer on asset 118 | 119 | 120 | # noinspection PyClassHasNoInit 121 | class PathPaymentResultCode(PaymentResultCode): 122 | TOO_FEW_OFFERS = 'op_too_few_offers' # not enough offers to satisfy path 123 | OFFER_CROSS_SELF = 'op_cross_self' # would cross one of its own offers 124 | OVER_SOURCE_MAX = 'op_over_source_max' # could not satisfy sendmax 125 | 126 | 127 | # noinspection PyClassHasNoInit 128 | class ManageOfferResultCode: 129 | SUCCESS = 'op_success' # operation successful 130 | MALFORMED = 'op_malformed' # generated offer would be invalid 131 | UNDERFUNDED = 'op_underfunded' # doesn't hold what it's trying to sell 132 | LINE_FULL = 'op_line_full' # can't receive more of what it's buying 133 | SELL_NO_TRUST = 'op_sell_no_trust' # no trust line for what we're selling 134 | BUY_NO_TRUST = 'op_buy_no_trust' # no trust line for what we're buying 135 | SELL_NOT_AUTHORIZED = 'sell_not_authorized' # not authorized to sell 136 | BUY_NOT_AUTHORIZED = 'buy_not_authorized' # not authorized to buy 137 | OFFER_CROSS_SELF = 'op_cross_self' # would cross an offer from the same user 138 | SELL_NO_ISSUER = 'op_sell_no_issuer' # no issuer for what we're selling 139 | BUY_NO_ISSUER = 'op_buy_no_issuer' # no issuer for what we're buying 140 | OFFER_NOT_FOUND = 'op_offer_not_found' # offerID does not match an existing offer 141 | OFFER_LOW_RESERVE = 'op_low_reserve' # not enough funds to create a new Offer 142 | 143 | 144 | # noinspection PyClassHasNoInit 145 | class SetOptionsResultCode: 146 | SUCCESS = 'op_success' # operation successful 147 | LOW_RESERVE = 'op_low_reserve' # not enough funds to add a signer 148 | TOO_MANY_SIGNERS = 'op_too_many_signers' # max number of signers already reached 149 | BAD_FLAGS = 'op_bad_flags' # invalid combination of clear/set flags 150 | INVALID_INFLATION = 'op_invalid_inflation' # inflation account does not exist 151 | CANT_CHANGE = 'op_cant_change' # can no longer change this option 152 | UNKNOWN_FLAG = 'op_unknown_flag' # can't set an unknown flag 153 | THRESHOLD_OUT_OF_RANGE = 'op_threshold_out_of_range' # bad value for weight/threshold 154 | BAD_SIGNER = 'op_bad_signer' # signer cannot be masterkey 155 | INVALID_HOME_DOMAIN = 'op_invalid_home_domain' # malformed home domain 156 | 157 | 158 | # noinspection PyClassHasNoInit 159 | class ChangeTrustResultCode: 160 | SUCCESS = 'op_success' # operation successful 161 | MALFORMED = 'op_malformed' # bad input 162 | NO_ISSUER = 'op_no_issuer' # could not find issuer 163 | LOW_RESERVE = 'op_low_reserve' # not enough funds to create a new trust line 164 | INVALID_LIMIT = 'op_invalid_limit' # cannot drop limit below balance, cannot create with a limit of 0 165 | 166 | 167 | # noinspection PyClassHasNoInit 168 | class AllowTrustResultCode: 169 | SUCCESS = 'op_success' # operation successful 170 | MALFORMED = 'op_malformed' # asset is not ASSET_TYPE_ALPHANUM 171 | NO_TRUST_LINE = 'op_no_trustline' # trustor does not have a trustline 172 | NOT_REQUIRED = 'op_not_required' # source account does not require trust 173 | CANT_REVOKE = 'op_cant_revoke' # source account can't revoke trust 174 | 175 | 176 | # noinspection PyClassHasNoInit 177 | class AccountMergeResultCode: 178 | SUCCESS = 'op_success' # operation successful 179 | MALFORMED = 'op_malformed' # can't merge onto itself 180 | NO_ACCOUNT = 'op_no_account' # destination does not exist 181 | IMMUTABLE_SET = 'op_immutable_set' # source account has AUTH_IMMUTABLE set 182 | HAS_SUB_ENTRIES = 'op_has_sub_entries' # account has trust lines/offers 183 | 184 | 185 | # noinspection PyClassHasNoInit 186 | class InflationResultCode: 187 | SUCCESS = 'op_success' 188 | NOT_TIME = 'op_not_time' 189 | -------------------------------------------------------------------------------- /kin/stellar/horizon.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -* 2 | 3 | # Copyright (C) 2018 Kin Foundation 4 | 5 | import requests 6 | from requests.adapters import HTTPAdapter, DEFAULT_POOLSIZE 7 | from requests.exceptions import RequestException 8 | import sys 9 | from time import sleep 10 | from urllib3.util import Retry 11 | 12 | from stellar_base.horizon import HORIZON_LIVE, HORIZON_TEST 13 | 14 | from .errors import HorizonError 15 | 16 | import logging 17 | logger = logging.getLogger(__name__) 18 | 19 | try: 20 | from sseclient import SSEClient 21 | except ImportError: 22 | SSEClient = None 23 | 24 | if sys.version[0] == '2': 25 | # noinspection PyUnresolvedReferences 26 | from urllib import urlencode 27 | else: 28 | # noinspection PyUnresolvedReferences 29 | from urllib.parse import urlencode 30 | 31 | 32 | DEFAULT_REQUEST_TIMEOUT = 11 # two ledgers + 1 sec, let's retry faster and not wait 60 secs. 33 | DEFAULT_NUM_RETRIES = 5 34 | DEFAULT_BACKOFF_FACTOR = 0.5 35 | USER_AGENT = 'py-stellar-base' 36 | 37 | 38 | class Horizon(object): 39 | """ 40 | This class redefines :class:`stellar_base.horizon.Horizon` to provide additional functionality: 41 | - persistent connection to Horizon and connection pool 42 | - configurable request retry functionality 43 | - Horizon error checking and deserialization 44 | """ 45 | def __init__(self, horizon_uri=None, pool_size=DEFAULT_POOLSIZE, num_retries=DEFAULT_NUM_RETRIES, 46 | request_timeout=DEFAULT_REQUEST_TIMEOUT, backoff_factor=DEFAULT_BACKOFF_FACTOR, user_agent=USER_AGENT): 47 | if horizon_uri is None: 48 | self.horizon_uri = HORIZON_TEST 49 | else: 50 | self.horizon_uri = horizon_uri 51 | 52 | self.pool_size = pool_size 53 | self.num_retries = num_retries 54 | self.request_timeout = request_timeout 55 | self.backoff_factor = backoff_factor 56 | 57 | # adding 504 to the list of statuses to retry 58 | self.status_forcelist = list(Retry.RETRY_AFTER_STATUS_CODES).append(504) 59 | 60 | # configure standard session 61 | 62 | # configure retry handler 63 | retry = Retry(total=self.num_retries, backoff_factor=self.backoff_factor, redirect=0, 64 | status_forcelist=self.status_forcelist) 65 | # init transport adapter 66 | adapter = HTTPAdapter(pool_connections=self.pool_size, pool_maxsize=self.pool_size, max_retries=retry) 67 | 68 | # init session 69 | session = requests.Session() 70 | 71 | # set default headers 72 | session.headers.update({'User-Agent': user_agent}) 73 | 74 | session.mount('http://', adapter) 75 | session.mount('https://', adapter) 76 | self._session = session 77 | 78 | # configure SSE session (differs from our standard session) 79 | 80 | sse_retry = Retry(total=1000000, redirect=0, status_forcelist=self.status_forcelist) 81 | sse_adapter = HTTPAdapter(pool_connections=self.pool_size, pool_maxsize=self.pool_size, max_retries=sse_retry) 82 | sse_session = requests.Session() 83 | sse_session.headers.update({'User-Agent': user_agent}) 84 | sse_session.mount('http://', sse_adapter) 85 | sse_session.mount('https://', sse_adapter) 86 | self._sse_session = sse_session 87 | 88 | def submit(self, te): 89 | """Submit the transaction using a pooled connection, and retry on failure.""" 90 | params = {'tx': te} 91 | url = self.horizon_uri + '/transactions/' 92 | 93 | # POST is not included in Retry's method_whitelist for a good reason. 94 | # our custom retry mechanism follows 95 | reply = None 96 | retry_count = self.num_retries 97 | while True: 98 | try: 99 | reply = self._session.post(url, data=params, timeout=self.request_timeout) 100 | return check_horizon_reply(reply.json()) 101 | except (RequestException, ValueError) as e: 102 | if reply: 103 | msg = 'horizon submit exception: {}, reply: [{}] {}'.format(str(e), reply.status_code, reply.text) 104 | else: 105 | msg = 'horizon submit exception: {}'.format(str(e)) 106 | logging.warning(msg) 107 | 108 | if reply and reply.status_code not in self.status_forcelist: 109 | raise Exception('invalid horizon reply: [{}] {}'.format(reply.status_code, reply.text)) 110 | # retry 111 | if retry_count <= 0: 112 | raise 113 | retry_count -= 1 114 | logging.warning('submit retry attempt {}'.format(retry_count)) 115 | sleep(self.backoff_factor) 116 | 117 | def query(self, rel_url, params=None, sse=False): 118 | abs_url = self.horizon_uri + rel_url 119 | reply = self._query(abs_url, params, sse) 120 | return check_horizon_reply(reply) if not sse else reply 121 | 122 | def account(self, address): 123 | url = '/accounts/' + address 124 | return self.query(url) 125 | 126 | def account_effects(self, address, params=None, sse=False): 127 | url = '/accounts/' + address + '/effects/' 128 | return self.query(url, params, sse) 129 | 130 | def account_offers(self, address, params=None): 131 | url = '/accounts/' + address + '/offers/' 132 | return self.query(url, params) 133 | 134 | def account_operations(self, address, params=None, sse=False): 135 | url = '/accounts/' + address + '/operations/' 136 | return self.query(url, params, sse) 137 | 138 | def account_transactions(self, address, params=None, sse=False): 139 | url = '/accounts/' + address + '/transactions/' 140 | return self.query(url, params, sse) 141 | 142 | def account_payments(self, address, params=None, sse=False): 143 | url = '/accounts/' + address + '/payments/' 144 | return self.query(url, params, sse) 145 | 146 | def transactions(self, params=None, sse=False): 147 | url = '/transactions/' 148 | return self.query(url, params, sse) 149 | 150 | def transaction(self, tx_hash): 151 | url = '/transactions/' + tx_hash 152 | return self.query(url) 153 | 154 | def transaction_operations(self, tx_hash, params=None): 155 | url = '/transactions/' + tx_hash + '/operations/' 156 | return self.query(url, params) 157 | 158 | def transaction_effects(self, tx_hash, params=None): 159 | url = '/transactions/' + tx_hash + '/effects/' 160 | return self.query(url, params) 161 | 162 | def transaction_payments(self, tx_hash, params=None): 163 | url = '/transactions/' + tx_hash + '/payments/' 164 | return self.query(url, params) 165 | 166 | def order_book(self, params=None): 167 | url = '/order_book/' 168 | return self.query(url, params) 169 | 170 | def trades(self, params=None): 171 | url = '/trades/' 172 | return self.query(url, params) 173 | 174 | def ledgers(self, params=None, sse=False): 175 | url = '/ledgers/' 176 | return self.query(url, params, sse) 177 | 178 | def ledger(self, ledger_id): 179 | url = '/ledgers/' + str(ledger_id) 180 | return self.query(url) 181 | 182 | def ledger_effects(self, ledger_id, params=None): 183 | url = '/ledgers/' + str(ledger_id) + '/effects/' 184 | return self.query(url, params) 185 | 186 | def ledger_operations(self, ledger_id, params=None): 187 | url = '/ledgers/' + str(ledger_id) + '/operations/' 188 | return self.query(url, params) 189 | 190 | def ledger_payments(self, ledger_id, params=None): 191 | url = '/ledgers/' + str(ledger_id) + '/payments/' 192 | return self.query(url, params) 193 | 194 | def effects(self, params=None, sse=False): 195 | url = '/effects/' 196 | return self.query(url, params, sse) 197 | 198 | def operations(self, params=None, sse=False): 199 | url = '/operations/' 200 | return self.query(url, params, sse) 201 | 202 | def operation(self, op_id, params=None): 203 | url = '/operations/' + str(op_id) 204 | return self.query(url, params) 205 | 206 | def operation_effects(self, op_id, params=None): 207 | url = '/operations/' + str(op_id) + '/effects/' 208 | return self.query(url, params) 209 | 210 | def payments(self, params=None, sse=False): 211 | url = '/payments/' 212 | return self.query(url, params, sse) 213 | 214 | def assets(self, params=None): 215 | url = '/assets/' 216 | return self.query(url, params) 217 | 218 | def _query(self, url, params=None, sse=False): 219 | if not sse: 220 | reply = self._session.get(url, params=params, timeout=self.request_timeout) 221 | try: 222 | return reply.json() 223 | except ValueError: 224 | raise Exception('invalid horizon reply: [{}] {}'.format(reply.status_code, reply.text)) 225 | 226 | # SSE connection 227 | if SSEClient is None: 228 | raise ValueError('SSE not supported, missing sseclient module') 229 | 230 | return SSEClient(url, session=self._sse_session, params=params) 231 | 232 | @staticmethod 233 | def testnet(): 234 | return Horizon(horizon_uri=HORIZON_TEST) 235 | 236 | @staticmethod 237 | def livenet(): 238 | return Horizon(horizon_uri=HORIZON_LIVE) 239 | 240 | 241 | def check_horizon_reply(reply): 242 | if 'status' not in reply: 243 | return reply 244 | raise HorizonError(reply) 245 | -------------------------------------------------------------------------------- /kin/stellar/horizon_models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -* 2 | 3 | # Copyright (C) 2018 Kin Foundation 4 | 5 | 6 | from schematics.models import Model 7 | from schematics.types import IntType, BooleanType, DecimalType, StringType, UTCDateTimeType 8 | from schematics.types.compound import ModelType, ListType, DictType 9 | 10 | 11 | class PModel(Model): 12 | """Base class for our models that provides printout capabilities""" 13 | def __str__(self): 14 | sb = [] 15 | for key in self.__dict__: 16 | if not key.startswith('__'): 17 | sb.append("\t{}='{}'".format(key, self.__dict__[key])) 18 | return '\n'.join(sb) 19 | 20 | def __repr__(self): 21 | return self.__str__() 22 | 23 | def __hash__(self): 24 | return hash(self.__str__()) 25 | 26 | 27 | class AccountData(PModel): 28 | class Thresholds(PModel): 29 | low_threshold = IntType(default=0) 30 | medium_threshold = IntType(default=0) 31 | high_threshold = IntType(default=0) 32 | 33 | class Flags(PModel): 34 | """Flags set on issuer accounts. 35 | TrustLines are created with authorized set to "false" requiring 36 | the issuer to set it for each TrustLine 37 | """ 38 | auth_required = BooleanType(default=False) # If set, the authorized flag in TrustLines can be cleared. 39 | # Otherwise, authorization cannot be revoked 40 | auth_revocable = BooleanType(default=False) # Once set, causes all AUTH_* flags to be read-only 41 | 42 | class Balance(PModel): 43 | asset_type = StringType() 44 | asset_code = StringType() 45 | asset_issuer = StringType() 46 | balance = DecimalType(default=0) 47 | limit = DecimalType() 48 | 49 | class Signer(PModel): 50 | public_key = StringType() 51 | key = StringType() 52 | weight = IntType() 53 | signature_type = StringType(serialized_name='type') 54 | 55 | id = StringType() 56 | account_id = StringType() 57 | sequence = StringType() 58 | data = DictType(StringType, default={}) 59 | thresholds = ModelType(Thresholds) 60 | balances = ListType(ModelType(Balance), default=[]) 61 | flags = ModelType(Flags) 62 | paging_token = StringType() 63 | subentry_count = IntType() 64 | signers = ListType(ModelType(Signer), default=[]) 65 | 66 | 67 | class OperationData(PModel): 68 | id = StringType() 69 | source_account = StringType() 70 | type = StringType() 71 | created_at = UTCDateTimeType() 72 | transaction_hash = StringType() 73 | asset_type = StringType() 74 | asset_code = StringType() 75 | asset_issuer = StringType() 76 | limit = DecimalType() 77 | trustor = StringType() 78 | trustee = StringType() 79 | from_address = StringType(serialized_name='from') 80 | to_address = StringType(serialized_name='to') 81 | amount = DecimalType() 82 | 83 | 84 | class TransactionData(PModel): 85 | id = StringType() 86 | hash = StringType() 87 | created_at = UTCDateTimeType() 88 | source_account = StringType() 89 | source_account_sequence = StringType() 90 | operations = ListType(ModelType(OperationData), default=[]) 91 | operation_count = IntType() 92 | ledger = StringType() 93 | memo_type = StringType() 94 | memo = StringType() 95 | fee_paid = DecimalType() 96 | signatures = ListType(StringType, default=[]) 97 | paging_token = StringType() 98 | envelope_xdr = StringType() 99 | result_xdr = StringType() 100 | result_meta_xdr = StringType() 101 | fee_meta_xdr = StringType() 102 | time_bounds = ListType(IntType, default=[]) 103 | 104 | 105 | class TransactionResultCodes(PModel): 106 | transaction = StringType() 107 | operations = ListType(StringType, default=[]) 108 | 109 | 110 | class HTTPProblemDetails(PModel): 111 | """HTTP Problem Details object. 112 | See https://tools.ietf.org/html/rfc7807 113 | """ 114 | class Extras(PModel): 115 | invalid_field = StringType() 116 | envelope_xdr = StringType() 117 | result_xdr = StringType() 118 | result_codes = ModelType(TransactionResultCodes) 119 | 120 | type = StringType() 121 | title = StringType() 122 | status = IntType() 123 | detail = StringType() 124 | instance = StringType() 125 | extras = ModelType(Extras) 126 | -------------------------------------------------------------------------------- /kin/stellar/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -* 2 | 3 | # Copyright (C) 2018 Kin Foundation 4 | 5 | from stellar_base.utils import decode_check 6 | 7 | 8 | def is_valid_address(address): 9 | """Determines if the provided string is a valid Stellar address. 10 | 11 | :param str address: address to check 12 | 13 | :return: True if this is a correct address 14 | :rtype: boolean 15 | """ 16 | if len(address) != 56: 17 | return False 18 | 19 | try: 20 | decode_check('account', address) 21 | return True 22 | except: 23 | return False 24 | 25 | 26 | def is_valid_secret_key(key): 27 | """Determines if the provided string is a valid Stellar key (seed). 28 | 29 | :param str key: key to check 30 | 31 | :return: True if this is a correct seed 32 | :rtype: boolean 33 | """ 34 | if len(key) != 56: 35 | return False 36 | 37 | try: 38 | decode_check('seed', key) 39 | return True 40 | except: 41 | return False 42 | 43 | 44 | def is_valid_transaction_hash(tx_hash): 45 | """Determines if the provided string is a valid Stellar transaction hash. 46 | 47 | :param str tx_hash: transaction hash to check 48 | 49 | :return: True if this is a correct transaction hash 50 | :rtype: boolean 51 | """ 52 | if len(tx_hash) != 64: 53 | return False 54 | 55 | try: 56 | int(tx_hash, 16) 57 | return True 58 | except: 59 | return False 60 | -------------------------------------------------------------------------------- /kin/version.py: -------------------------------------------------------------------------------- 1 | 2 | __version__ = "0.2.6" 3 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | attrs==17.4.0 2 | codecov==2.0.15 3 | coverage==4.5.1 4 | funcsigs==1.0.2 5 | pluggy==0.6.0 6 | py==1.5.2 7 | pytest==3.4.0 8 | pytest-cov==2.5.1 9 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | certifi==2018.1.18 2 | chardet==3.0.4 3 | crc16==0.1.1 4 | ed25519==1.4 5 | idna==2.6 6 | mnemonic==0.18 7 | numpy==1.15.2 8 | pbkdf2==1.3 9 | requests==2.20.0 10 | schematics==2.0.1 11 | six==1.11.0 12 | sseclient==0.0.18 13 | stellar-base==0.1.8.1 14 | toml==0.9.4 15 | urllib3==1.24.2 16 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | # This flag says that the code is written to work on both Python 2 and Python 3. 3 | universal=1 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import setup, find_packages 4 | 5 | exec(open("kin/version.py").read()) 6 | 7 | with open('requirements.txt') as f: 8 | requires = [line.strip() for line in f if line.strip()] 9 | with open('requirements-dev.txt') as f: 10 | tests_requires = [line.strip() for line in f if line.strip()] 11 | 12 | setup( 13 | name='kin', 14 | version=__version__, 15 | description='KIN Stellar SDK for Python', 16 | author='Kin Foundation', 17 | author_email='david.bolshoy@kik.com', 18 | maintainer='David Bolshoy', 19 | maintainer_email='david.bolshoy@kik.com', 20 | url='https://github.com/kinecosystem/kin-core-python', 21 | license='MIT', 22 | packages=find_packages(), 23 | long_description=open("README.md").read(), 24 | keywords=["kin", "stellar", "blockchain", "cryptocurrency"], 25 | classifiers=[ 26 | 'License :: OSI Approved :: MIT License', 27 | 'Intended Audience :: Developers', 28 | 'Development Status :: 0 - Alpha/unstable', 29 | 'Topic :: Software Development :: Libraries :: Python Modules', 30 | 'Programming Language :: Python', 31 | 'Programming Language :: Python :: 2.7', 32 | 'Programming Language :: Python :: 3', 33 | ], 34 | install_requires=requires, 35 | tests_require=tests_requires, 36 | python_requires='>=2.7', 37 | ) 38 | -------------------------------------------------------------------------------- /stellar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kinecosystem/kin-sdk-python/c09dc321c8970d70016e304ad45868e5d4e6a773/stellar.png -------------------------------------------------------------------------------- /test/conftest.py: -------------------------------------------------------------------------------- 1 | import json 2 | import pytest 3 | import requests 4 | 5 | from stellar_base.asset import Asset 6 | from stellar_base.keypair import Keypair 7 | 8 | import kin 9 | from kin.stellar.builder import Builder 10 | 11 | import logging 12 | logging.basicConfig() 13 | #logging.getLogger().setLevel(logging.DEBUG) 14 | 15 | 16 | def pytest_addoption(parser): 17 | parser.addoption("--testnet", action="store_true", default=False, help="whether testing on testnet instead of local") 18 | 19 | 20 | @pytest.fixture(scope='session') 21 | def testnet(request): 22 | return request.config.getoption("--testnet") 23 | 24 | 25 | @pytest.fixture(scope='session') 26 | def setup(testnet): 27 | class Struct: 28 | """Handy variable holder""" 29 | def __init__(self, **entries): self.__dict__.update(entries) 30 | 31 | sdk_keypair = Keypair.random() 32 | issuer_keypair = Keypair.random() 33 | test_asset = Asset('TEST', issuer_keypair.address().decode()) 34 | 35 | # global testnet 36 | if testnet: 37 | from stellar_base.horizon import HORIZON_TEST 38 | return Struct(type='testnet', 39 | network='TESTNET', 40 | sdk_keypair=sdk_keypair, 41 | issuer_keypair=issuer_keypair, 42 | test_asset=test_asset, 43 | horizon_endpoint_uri=HORIZON_TEST) 44 | 45 | # local testnet (zulucrypto docker) 46 | # https://github.com/zulucrypto/docker-stellar-integration-test-network 47 | from stellar_base.network import NETWORKS 48 | NETWORKS['CUSTOM'] = 'Integration Test Network ; zulucrypto' 49 | return Struct(type='local', 50 | network='CUSTOM', 51 | sdk_keypair=sdk_keypair, 52 | issuer_keypair=issuer_keypair, 53 | test_asset=test_asset, 54 | horizon_endpoint_uri='http://localhost:8000') 55 | 56 | 57 | @pytest.fixture(scope='session') 58 | def test_sdk(setup): 59 | # create and fund sdk account 60 | Helpers.fund_account(setup, setup.sdk_keypair.address().decode()) 61 | 62 | # create and fund issuer account 63 | Helpers.fund_account(setup, setup.issuer_keypair.address().decode()) 64 | 65 | # create a trustline from sdk to asset 66 | Helpers.trust_asset(setup, setup.sdk_keypair.seed()) 67 | 68 | # init sdk 69 | sdk = kin.SDK(secret_key=setup.sdk_keypair.seed(), horizon_endpoint_uri=setup.horizon_endpoint_uri, 70 | network=setup.network, kin_asset=setup.test_asset) 71 | assert sdk 72 | print("""test_sdk fixture created with the following setup: 73 | type: {} 74 | network: {} 75 | sdk keypair: {} {} 76 | issuer keypair: {} {} 77 | asset: {} {} 78 | horizon uri: {} 79 | """.format(setup.type, setup.network, 80 | setup.sdk_keypair.seed(), setup.sdk_keypair.address().decode(), 81 | setup.issuer_keypair.seed(), setup.issuer_keypair.address().decode(), 82 | setup.test_asset.code, setup.test_asset.issuer, 83 | setup.horizon_endpoint_uri)) 84 | return sdk 85 | 86 | 87 | class Helpers: 88 | """A container for helper functions available to all tests""" 89 | @staticmethod 90 | def fund_account(setup, address): 91 | for attempt in range(3): 92 | try: 93 | if setup.type == 'local': 94 | r = requests.get(setup.horizon_endpoint_uri + '/friendbot?addr=' + address) 95 | else: 96 | r = requests.get('https://friendbot.stellar.org/?addr=' + address) 97 | j = json.loads(r.text) 98 | if 'hash' in j: 99 | print('\naccount {} funded successfully'.format(address)) 100 | return 101 | elif 'op_already_exists' in j: 102 | print('\naccount {} already exists, not funded'.format(address)) 103 | return 104 | except Exception as e: 105 | print('\naccount {} funding error: {} {}'.format(address, r.status_code, r.text)) 106 | raise Exception('account {} funding failed'.format(address)) 107 | 108 | @staticmethod 109 | def trust_asset(setup, secret_key, memo_text=None): 110 | """A helper to establish a trustline""" 111 | builder = Builder(secret=secret_key, horizon_uri=setup.horizon_endpoint_uri, network=setup.network) 112 | builder.append_trust_op(setup.test_asset.issuer, setup.test_asset.code) 113 | if memo_text: 114 | builder.add_text_memo(memo_text[:28]) # max memo length is 28 115 | builder.sign() 116 | reply = builder.submit() 117 | return reply.get('hash') 118 | 119 | @classmethod 120 | def fund_asset(cls, setup, address, amount, memo_text=None): 121 | """A helper to fund the SDK account with asset""" 122 | return cls.send_asset(setup, setup.issuer_keypair.seed(), address, amount, memo_text) 123 | 124 | @classmethod 125 | def send_asset(cls, setup, secret_key, address, amount, memo_text=None): 126 | """A helper to send asset""" 127 | builder = Builder(secret=secret_key, horizon_uri=setup.horizon_endpoint_uri, network=setup.network) 128 | builder.append_payment_op(address, amount, asset_type=setup.test_asset.code, 129 | asset_issuer=setup.test_asset.issuer) 130 | if memo_text: 131 | builder.add_text_memo(memo_text[:28]) # max memo length is 28 132 | builder.sign() 133 | reply = builder.submit() 134 | return reply.get('hash') 135 | 136 | 137 | @pytest.fixture 138 | def helpers(): 139 | return Helpers 140 | 141 | 142 | -------------------------------------------------------------------------------- /test/test_builder.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from stellar_base.keypair import Keypair 4 | from kin.stellar.builder import Builder 5 | from kin.stellar.horizon import Horizon, HORIZON_LIVE, HORIZON_TEST 6 | 7 | 8 | def test_create_fail(): 9 | with pytest.raises(Exception, match='either secret or address must be provided'): 10 | Builder() 11 | with pytest.raises(Exception, match='invalid secret key'): 12 | Builder(secret='bad') 13 | with pytest.raises(Exception, match='invalid address'): 14 | Builder(address='bad') 15 | 16 | 17 | def test_create_default(): 18 | keypair = Keypair.random() 19 | 20 | # with secret 21 | builder = Builder(secret=keypair.seed()) 22 | assert builder 23 | assert builder.key_pair.seed() == keypair.seed() 24 | assert builder.address == keypair.address().decode() 25 | assert builder.network == 'PUBLIC' 26 | assert builder.horizon 27 | assert builder.horizon.horizon_uri == HORIZON_LIVE 28 | 29 | # with address 30 | builder = Builder(address=keypair.address().decode()) 31 | assert builder 32 | assert builder.address == keypair.address().decode() 33 | assert builder.network == 'PUBLIC' 34 | assert builder.horizon 35 | assert builder.horizon.horizon_uri == HORIZON_LIVE 36 | 37 | # on testnet 38 | builder = Builder(secret=keypair.seed(), network='TESTNET') 39 | assert builder 40 | assert builder.network == 'TESTNET' 41 | assert builder.horizon 42 | assert builder.horizon.horizon_uri == HORIZON_TEST 43 | 44 | 45 | def test_create_custom(test_sdk): 46 | keypair = Keypair.random() 47 | 48 | builder = Builder(secret=keypair.seed(), horizon_uri='custom', network='custom') 49 | assert builder 50 | assert builder.horizon 51 | assert builder.horizon.horizon_uri == 'custom' 52 | assert builder.network == 'CUSTOM' 53 | 54 | # with custom horizon 55 | horizon = Horizon() 56 | builder = Builder(secret=keypair.seed(), horizon=horizon) 57 | assert builder 58 | assert builder.horizon == horizon 59 | 60 | # with horizon fixture 61 | builder = Builder(secret=test_sdk.base_keypair.seed(), horizon=test_sdk.horizon, network=test_sdk.network) 62 | assert builder 63 | 64 | 65 | @pytest.fixture(scope='session') 66 | def test_builder(test_sdk): 67 | builder = Builder(secret=test_sdk.base_keypair.seed(), horizon=test_sdk.horizon, network=test_sdk.network) 68 | assert builder 69 | return builder 70 | 71 | 72 | def test_sign(test_builder): 73 | test_builder.append_create_account_op(Keypair.random().address().decode(), 100) 74 | assert len(test_builder.ops) == 1 75 | test_builder.sign() 76 | assert test_builder.te 77 | assert test_builder.tx 78 | 79 | 80 | def test_clear(test_builder): 81 | test_builder.clear() 82 | assert len(test_builder.ops) == 0 83 | assert not test_builder.te 84 | assert not test_builder.tx 85 | 86 | 87 | def test_get_sequence(test_builder): 88 | assert test_builder.get_sequence() 89 | 90 | 91 | def test_next(test_builder): 92 | sequence = test_builder.get_sequence() 93 | test_builder.append_create_account_op(Keypair.random().address().decode(), 100) 94 | test_builder.sign() 95 | test_builder.next() 96 | assert not test_builder.tx 97 | assert not test_builder.te 98 | assert test_builder.sequence == str(int(sequence) + 1) 99 | 100 | -------------------------------------------------------------------------------- /test/test_errors.py: -------------------------------------------------------------------------------- 1 | from requests.exceptions import RequestException 2 | 3 | import kin 4 | from kin.errors import translate_error, translate_horizon_error 5 | from kin.stellar.errors import * 6 | 7 | 8 | def test_sdk_error(): 9 | e = kin.SdkError(message='message', error_code=1, extra={'key': 'value'}) 10 | assert e.message == 'message' 11 | assert e.error_code == 1 12 | assert e.extra == {'key': 'value'} 13 | str(e) # cover __str__ method 14 | 15 | 16 | def test_translate_error(): 17 | e = translate_error(RequestException('error')) 18 | assert isinstance(e, kin.NetworkError) 19 | assert e.extra['internal_error'] == 'error' 20 | 21 | e = translate_error(Exception('error')) 22 | assert isinstance(e, kin.InternalError) 23 | assert e.extra['internal_error'] == 'error' 24 | 25 | 26 | def test_translate_horizon_error(): 27 | err_dict = dict(title='title', status=400, detail='detail', instance='instance', extras={}) 28 | 29 | fixtures = [ 30 | # RequestError 31 | [HorizonErrorType.BAD_REQUEST, kin.RequestError, 'bad request', {}], 32 | [HorizonErrorType.FORBIDDEN, kin.RequestError, 'bad request', {}], 33 | [HorizonErrorType.NOT_ACCEPTABLE, kin.RequestError, 'bad request', {}], 34 | [HorizonErrorType.UNSUPPORTED_MEDIA_TYPE, kin.RequestError, 'bad request', {}], 35 | [HorizonErrorType.NOT_IMPLEMENTED, kin.RequestError, 'bad request', {}], 36 | [HorizonErrorType.BEFORE_HISTORY, kin.RequestError, 'bad request', {}], 37 | [HorizonErrorType.STALE_HISTORY, kin.RequestError, 'bad request', {}], 38 | [HorizonErrorType.TRANSACTION_MALFORMED, kin.RequestError, 'bad request', {}], 39 | 40 | # ResourceNotFoundError 41 | [HorizonErrorType.NOT_FOUND, kin.ResourceNotFoundError, 'resource not found', {}], 42 | 43 | # ServerError 44 | [HorizonErrorType.RATE_LIMIT_EXCEEDED, kin.ServerError, 'server error', {}], 45 | [HorizonErrorType.SERVER_OVER_CAPACITY, kin.ServerError, 'server error', {}], 46 | 47 | # InternalError 48 | [HorizonErrorType.INTERNAL_SERVER_ERROR, kin.InternalError, 'internal error', {}], 49 | ['unknown', kin.InternalError, 'internal error', {'internal_error': 'unknown horizon error'}], 50 | ] 51 | 52 | for fixture in fixtures: 53 | err_dict['type'] = HORIZON_NS_PREFIX + fixture[0] 54 | e = translate_horizon_error(HorizonError(err_dict)) 55 | assert isinstance(e, fixture[1]) 56 | assert e.error_code == fixture[0] 57 | assert e.message == fixture[2] 58 | 59 | 60 | def test_translate_transaction_error(): 61 | err_dict = dict(type=HORIZON_NS_PREFIX + HorizonErrorType.TRANSACTION_FAILED, title='title', status=400, 62 | detail='detail', instance='instance', 63 | extras={'result_codes': {'operations': [], 'transaction': 'tx_failed'}}) 64 | 65 | fixtures = [ 66 | # RequestError 67 | [TransactionResultCode.TOO_EARLY, kin.RequestError, 'bad request', {}], 68 | [TransactionResultCode.TOO_LATE, kin.RequestError, 'bad request', {}], 69 | [TransactionResultCode.MISSING_OPERATION, kin.RequestError, 'bad request', {}], 70 | [TransactionResultCode.BAD_AUTH, kin.RequestError, 'bad request', {}], 71 | [TransactionResultCode.BAD_AUTH_EXTRA, kin.RequestError, 'bad request', {}], 72 | [TransactionResultCode.BAD_SEQUENCE, kin.RequestError, 'bad request', {}], 73 | [TransactionResultCode.INSUFFICIENT_FEE, kin.RequestError, 'bad request', {}], 74 | 75 | # AccountNotFoundError 76 | [TransactionResultCode.NO_ACCOUNT, kin.AccountNotFoundError, 'account not found', {}], 77 | 78 | # LowBalanceError 79 | [TransactionResultCode.INSUFFICIENT_BALANCE, kin.LowBalanceError, 'low balance', {}], 80 | 81 | # InternalError 82 | ['unknown', kin.InternalError, 'internal error', {'internal_error': 'unknown transaction error'}] 83 | ] 84 | 85 | for fixture in fixtures: 86 | err_dict['extras']['result_codes']['transaction'] = fixture[0] 87 | e = translate_horizon_error(HorizonError(err_dict)) 88 | assert isinstance(e, fixture[1]) 89 | assert e.error_code == fixture[0] 90 | assert e.message == fixture[2] 91 | assert e.extra == fixture[3] 92 | 93 | 94 | def test_translate_operation_error(): 95 | # RequestError 96 | err_dict = dict(type=HORIZON_NS_PREFIX + HorizonErrorType.TRANSACTION_FAILED, title='title', status=400, 97 | detail='detail', instance='instance', 98 | extras={'result_codes': {'operations': [], 'transaction': 'tx_failed'}}) 99 | 100 | fixtures = [ 101 | # RequestError 102 | [OperationResultCode.BAD_AUTH, kin.RequestError, 'bad request', {}], 103 | [CreateAccountResultCode.MALFORMED, kin.RequestError, 'bad request', {}], 104 | [PaymentResultCode.NO_ISSUER, kin.RequestError, 'bad request', {}], 105 | [PaymentResultCode.LINE_FULL, kin.RequestError, 'bad request', {}], 106 | [ChangeTrustResultCode.INVALID_LIMIT, kin.RequestError, 'bad request', {}], 107 | 108 | # AccountNotFoundError 109 | [OperationResultCode.NO_ACCOUNT, kin.AccountNotFoundError, 'account not found', {}], 110 | [PaymentResultCode.NO_DESTINATION, kin.AccountNotFoundError, 'account not found', {}], 111 | 112 | # AccountExistsError 113 | [CreateAccountResultCode.ACCOUNT_EXISTS, kin.AccountExistsError, 'account already exists', {}], 114 | 115 | # LowBalanceError 116 | [CreateAccountResultCode.LOW_RESERVE, kin.LowBalanceError, 'low balance', {}], 117 | [PaymentResultCode.UNDERFUNDED, kin.LowBalanceError, 'low balance', {}], 118 | 119 | # AccountNotActivatedError 120 | [PaymentResultCode.SRC_NO_TRUST, kin.AccountNotActivatedError, 'account not activated', {}], 121 | [PaymentResultCode.NO_TRUST, kin.AccountNotActivatedError, 'account not activated', {}], 122 | [PaymentResultCode.SRC_NOT_AUTHORIZED, kin.AccountNotActivatedError, 'account not activated', {}], 123 | [PaymentResultCode.NOT_AUTHORIZED, kin.AccountNotActivatedError, 'account not activated', {}], 124 | 125 | # InternalError 126 | ['unknown', kin.InternalError, 'internal error', {'internal_error': 'unknown operation error'}] 127 | ] 128 | 129 | for fixture in fixtures: 130 | err_dict['extras']['result_codes']['operations'] = [fixture[0]] 131 | e = translate_horizon_error(HorizonError(err_dict)) 132 | assert isinstance(e, fixture[1]) 133 | assert e.error_code == fixture[0] 134 | assert e.message == fixture[2] 135 | assert e.extra == fixture[3] 136 | 137 | -------------------------------------------------------------------------------- /test/test_horizon.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from requests.adapters import DEFAULT_POOLSIZE 3 | 4 | from stellar_base.horizon import HORIZON_TEST, HORIZON_LIVE 5 | from kin.stellar.errors import * 6 | from kin.stellar.horizon import ( 7 | Horizon, 8 | check_horizon_reply, 9 | DEFAULT_REQUEST_TIMEOUT, 10 | DEFAULT_NUM_RETRIES, 11 | DEFAULT_BACKOFF_FACTOR, 12 | USER_AGENT, 13 | ) 14 | 15 | 16 | def test_check_horizon_reply(): 17 | reply = { 18 | 'type': HORIZON_NS_PREFIX + HorizonErrorType.TRANSACTION_FAILED, 19 | 'status': 400, 20 | 'title': 'title', 21 | 'extras': { 22 | 'result_codes': { 23 | 'operations': [PaymentResultCode.NO_TRUST], 24 | 'transaction': TransactionResultCode.FAILED 25 | } 26 | } 27 | } 28 | with pytest.raises(HorizonError) as exc_info: 29 | check_horizon_reply(reply) 30 | assert exc_info.value.type == HorizonErrorType.TRANSACTION_FAILED 31 | 32 | reply = "{'a':'b'}" 33 | check_horizon_reply(reply) 34 | 35 | 36 | def test_defaults(): 37 | horizon = Horizon.testnet() 38 | assert horizon 39 | assert horizon.horizon_uri == HORIZON_TEST 40 | 41 | horizon = Horizon.livenet() 42 | assert horizon 43 | assert horizon.horizon_uri == HORIZON_LIVE 44 | 45 | 46 | def test_create_default(): 47 | horizon = Horizon() 48 | assert horizon 49 | assert horizon.horizon_uri == HORIZON_TEST 50 | assert horizon.request_timeout == DEFAULT_REQUEST_TIMEOUT 51 | assert horizon._session 52 | assert horizon._session.headers['User-Agent'] == USER_AGENT 53 | assert horizon._session.adapters['http://'] 54 | assert horizon._session.adapters['https://'] 55 | adapter = horizon._session.adapters['http://'] 56 | assert adapter.max_retries 57 | assert adapter.max_retries.total == DEFAULT_NUM_RETRIES 58 | assert adapter.max_retries.backoff_factor == DEFAULT_BACKOFF_FACTOR 59 | assert adapter.max_retries.redirect == 0 60 | assert adapter._pool_connections == DEFAULT_POOLSIZE 61 | assert adapter._pool_maxsize == DEFAULT_POOLSIZE 62 | 63 | 64 | def test_create_custom(): 65 | horizon_uri = 'horizon_uri' 66 | pool_size = 5 67 | num_retries = 10 68 | request_timeout = 30 69 | backoff_factor = 5 70 | horizon = Horizon(horizon_uri=horizon_uri, pool_size=pool_size, num_retries=num_retries, 71 | request_timeout=request_timeout, backoff_factor=backoff_factor) 72 | assert horizon 73 | assert horizon.horizon_uri == horizon_uri 74 | assert horizon.request_timeout == request_timeout 75 | assert horizon._session.headers['User-Agent'] == USER_AGENT 76 | adapter = horizon._session.adapters['http://'] 77 | assert adapter.max_retries.total == num_retries 78 | assert adapter.max_retries.backoff_factor == backoff_factor 79 | assert adapter.max_retries.redirect == 0 80 | assert adapter._pool_connections == pool_size 81 | assert adapter._pool_maxsize == pool_size 82 | 83 | 84 | def test_account(test_sdk): 85 | with pytest.raises(HorizonError) as exc_info: 86 | test_sdk.horizon.account('bad') 87 | assert exc_info.value.type == HorizonErrorType.NOT_FOUND 88 | 89 | address = test_sdk.get_address() 90 | reply = test_sdk.horizon.account(address) 91 | assert reply 92 | assert reply['id'] 93 | 94 | 95 | def test_account_effects(test_sdk): 96 | with pytest.raises(HorizonError) as exc_info: 97 | test_sdk.horizon.account_effects('bad') 98 | assert exc_info.value.type == HorizonErrorType.NOT_FOUND 99 | 100 | address = test_sdk.get_address() 101 | reply = test_sdk.horizon.account_effects(address) 102 | assert reply 103 | assert reply['_embedded']['records'] 104 | 105 | 106 | def test_account_offers(test_sdk): 107 | # does not raise on nonexistent account! 108 | 109 | address = test_sdk.get_address() 110 | reply = test_sdk.horizon.account_offers(address) 111 | assert reply 112 | assert reply['_embedded'] 113 | 114 | 115 | def test_account_operations(test_sdk): 116 | with pytest.raises(HorizonError) as exc_info: 117 | test_sdk.horizon.account_operations('bad') 118 | assert exc_info.value.type == HorizonErrorType.NOT_FOUND 119 | 120 | address = test_sdk.get_address() 121 | reply = test_sdk.horizon.account_operations(address) 122 | assert reply 123 | assert reply['_embedded']['records'] 124 | 125 | 126 | def test_account_transactions(test_sdk): 127 | with pytest.raises(HorizonError) as exc_info: 128 | test_sdk.horizon.account_transactions('bad') 129 | assert exc_info.value.type == HorizonErrorType.NOT_FOUND 130 | 131 | address = test_sdk.get_address() 132 | reply = test_sdk.horizon.account_transactions(address) 133 | assert reply 134 | assert reply['_embedded']['records'] 135 | 136 | 137 | def test_account_payments(test_sdk): 138 | with pytest.raises(HorizonError) as exc_info: 139 | test_sdk.horizon.account_payments('bad') 140 | assert exc_info.value.type == HorizonErrorType.NOT_FOUND 141 | 142 | address = test_sdk.get_address() 143 | reply = test_sdk.horizon.account_payments(address) 144 | assert reply 145 | assert reply['_embedded']['records'] 146 | 147 | 148 | def test_transactions(test_sdk): 149 | reply = test_sdk.horizon.transactions() 150 | assert reply 151 | assert reply['_embedded']['records'] 152 | 153 | 154 | def get_first_tx_hash(test_sdk): 155 | if not hasattr(test_sdk, 'first_tx_hash'): 156 | reply = test_sdk.horizon.account_transactions(test_sdk.get_address()) 157 | assert reply 158 | tx = reply['_embedded']['records'][0] 159 | assert tx['hash'] 160 | test_sdk.first_tx_hash = tx['hash'] 161 | return test_sdk.first_tx_hash 162 | 163 | 164 | def test_transaction(test_sdk): 165 | with pytest.raises(HorizonError) as exc_info: 166 | test_sdk.horizon.transaction('bad') 167 | assert exc_info.value.type == HorizonErrorType.NOT_FOUND 168 | 169 | tx_id = get_first_tx_hash(test_sdk) 170 | reply = test_sdk.horizon.transaction(tx_id) 171 | assert reply 172 | assert reply['id'] == tx_id 173 | 174 | assert reply['operation_count'] == 1 175 | 176 | 177 | def test_transaction_effects(test_sdk): 178 | with pytest.raises(HorizonError) as exc_info: 179 | test_sdk.horizon.transaction_effects('bad') 180 | assert exc_info.value.type == HorizonErrorType.NOT_FOUND 181 | 182 | tx_id = get_first_tx_hash(test_sdk) 183 | reply = test_sdk.horizon.transaction_effects(tx_id) 184 | assert reply 185 | assert reply['_embedded']['records'] 186 | 187 | 188 | def test_transaction_operations(test_sdk): 189 | with pytest.raises(HorizonError) as exc_info: 190 | test_sdk.horizon.transaction_operations('bad') 191 | assert exc_info.value.type == HorizonErrorType.NOT_FOUND 192 | 193 | tx_id = get_first_tx_hash(test_sdk) 194 | reply = test_sdk.horizon.transaction_operations(tx_id) 195 | assert reply 196 | assert reply['_embedded']['records'] 197 | 198 | 199 | def test_transaction_payments(test_sdk): 200 | with pytest.raises(HorizonError) as exc_info: 201 | test_sdk.horizon.transaction_payments('bad') 202 | assert exc_info.value.type == HorizonErrorType.NOT_FOUND 203 | 204 | tx_id = get_first_tx_hash(test_sdk) 205 | reply = test_sdk.horizon.transaction_payments(tx_id) 206 | assert reply 207 | assert reply['_embedded']['records'] 208 | 209 | 210 | def test_order_book(setup, test_sdk): 211 | params = { 212 | 'selling_asset_type': 'credit_alphanum4', 213 | 'selling_asset_code': setup.test_asset.code, 214 | 'selling_asset_issuer': setup.test_asset.issuer, 215 | 'buying_asset_type': 'native', 216 | 'buying_asset_code': 'XLM', 217 | } 218 | reply = test_sdk.horizon.order_book(params=params) 219 | assert reply 220 | assert reply['base']['asset_code'] == setup.test_asset.code 221 | 222 | 223 | def test_trades(setup, test_sdk): 224 | if setup.type == 'testnet': # TODO: returns 404 for local horizon 225 | # all trades 226 | reply = test_sdk.horizon.trades() 227 | assert reply['_embedded']['records'] 228 | 229 | # specific trades (taken from tesnet horizon) 230 | params = { 231 | 'base_asset_type': 'credit_alphanum4', 232 | 'base_asset_code': 'BTC', 233 | 'base_asset_issuer': 'GBB7JKBP5ZG7UUHAOYDOHQMIVDRKNMXTCDU3WUDVRV77NZJBEJNL4F2H', 234 | 'counter_asset_type': 'credit_alphanum4', 235 | 'counter_asset_code': 'XLM', 236 | 'counter_asset_issuer': 'GBB7JKBP5ZG7UUHAOYDOHQMIVDRKNMXTCDU3WUDVRV77NZJBEJNL4F2H', 237 | } 238 | reply = test_sdk.horizon.trades(params=params) 239 | assert reply['_embedded']['records'] 240 | 241 | 242 | def test_ledgers(test_sdk): 243 | reply = test_sdk.horizon.ledgers() 244 | assert reply 245 | assert reply['_embedded']['records'] 246 | 247 | 248 | def test_ledger(test_sdk): 249 | with pytest.raises(HorizonError) as exc_info: 250 | test_sdk.horizon.ledger('bad') 251 | assert exc_info.value.type == HorizonErrorType.BAD_REQUEST # not 'Resource Missing'! 252 | 253 | reply = test_sdk.horizon.ledger(2) 254 | assert reply 255 | assert reply['sequence'] == 2 256 | 257 | 258 | def test_ledger_effects(test_sdk): 259 | with pytest.raises(HorizonError, match='Bad Request') as exc_info: 260 | test_sdk.horizon.ledger_effects('bad') 261 | assert exc_info.value.type == HorizonErrorType.BAD_REQUEST # not 'Resource Missing'! 262 | 263 | reply = test_sdk.horizon.ledger_effects(2) 264 | assert reply 265 | assert reply['_embedded'] 266 | 267 | 268 | def test_ledger_operations(test_sdk): 269 | with pytest.raises(HorizonError) as exc_info: 270 | test_sdk.horizon.ledger_operations('bad') 271 | assert exc_info.value.type == HorizonErrorType.BAD_REQUEST # not 'Resource Missing'! 272 | 273 | reply = test_sdk.horizon.ledger_operations(2) 274 | assert reply 275 | assert reply['_embedded'] 276 | 277 | 278 | def test_ledger_payments(test_sdk): 279 | with pytest.raises(HorizonError) as exc_info: 280 | test_sdk.horizon.ledger_payments('bad') 281 | assert exc_info.value.type == HorizonErrorType.BAD_REQUEST # not 'Resource Missing'! 282 | 283 | reply = test_sdk.horizon.ledger_payments(2) 284 | assert reply 285 | assert reply['_embedded'] 286 | 287 | 288 | def test_effects(test_sdk): 289 | reply = test_sdk.horizon.effects() 290 | assert reply 291 | assert reply['_embedded']['records'] 292 | 293 | 294 | def test_operations(test_sdk): 295 | reply = test_sdk.horizon.operations() 296 | assert reply 297 | assert reply['_embedded']['records'] 298 | 299 | 300 | def test_operation(test_sdk): 301 | with pytest.raises(HorizonError) as exc_info: 302 | test_sdk.horizon.operation('bad') 303 | assert exc_info.value.type == HorizonErrorType.BAD_REQUEST # not 'Resource Missing'! 304 | 305 | reply = test_sdk.horizon.operations() 306 | op_id = reply['_embedded']['records'][0]['id'] 307 | 308 | reply = test_sdk.horizon.operation(op_id) 309 | assert reply 310 | assert reply['id'] == op_id 311 | 312 | 313 | def test_operation_effects(test_sdk): 314 | with pytest.raises(HorizonError) as exc_info: 315 | test_sdk.horizon.operation_effects('bad') 316 | assert exc_info.value.type == HorizonErrorType.BAD_REQUEST # not 'Resource Missing'! 317 | 318 | reply = test_sdk.horizon.operations() 319 | op_id = reply['_embedded']['records'][0]['id'] 320 | 321 | reply = test_sdk.horizon.operation_effects(op_id) 322 | assert reply 323 | assert reply['_embedded']['records'] 324 | 325 | 326 | def test_payments(test_sdk): 327 | reply = test_sdk.horizon.payments() 328 | assert reply 329 | assert reply['_embedded']['records'] 330 | 331 | 332 | def test_assets(test_sdk): 333 | # TODO: 'Resource Missing' with local docker 334 | # reply = test_sdk.horizon.assets() 335 | # assert reply 336 | # assert reply['_embedded']['records'] 337 | pass 338 | 339 | 340 | def test_horizon_error_hashable(test_sdk): 341 | err_dict = dict(title='title', 342 | status=400, 343 | detail='detail', 344 | instance='instance', 345 | extras={}, 346 | type=HORIZON_NS_PREFIX + HorizonErrorType.BAD_REQUEST) 347 | e = HorizonError(err_dict) 348 | {e: 1} # shouldn't fail on unhashable type 349 | -------------------------------------------------------------------------------- /test/test_sdk.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | import pytest 3 | import threading 4 | from time import sleep 5 | 6 | from stellar_base.asset import Asset 7 | from stellar_base.keypair import Keypair 8 | from stellar_base.utils import XdrLengthError 9 | 10 | import kin 11 | 12 | 13 | def test_sdk_not_configured(setup): 14 | sdk = kin.SDK(horizon_endpoint_uri=setup.horizon_endpoint_uri, network=setup.network) 15 | with pytest.raises(kin.SdkError, match='address not configured'): 16 | sdk.get_address() 17 | with pytest.raises(kin.SdkError, match='address not configured'): 18 | sdk.get_native_balance() 19 | with pytest.raises(kin.SdkError, match='address not configured'): 20 | sdk.get_kin_balance() 21 | with pytest.raises(kin.SdkError, match='address not configured'): 22 | sdk.create_account('address') 23 | with pytest.raises(kin.SdkError, match='address not configured'): 24 | sdk.monitor_kin_payments(None) 25 | with pytest.raises(kin.SdkError, match='address not configured'): 26 | sdk._trust_asset(Asset('TMP', 'tmp')) 27 | with pytest.raises(kin.SdkError, match='address not configured'): 28 | sdk._send_asset(Asset('TMP', 'tmp'), 'address', 1) 29 | 30 | 31 | def test_sdk_create_fail(setup, helpers, test_sdk): 32 | with pytest.raises(ValueError, match='invalid secret key: bad'): 33 | kin.SDK(secret_key='bad', 34 | horizon_endpoint_uri=setup.horizon_endpoint_uri, network=setup.network, kin_asset=setup.test_asset) 35 | 36 | keypair = Keypair.random() 37 | secret_key = keypair.seed() 38 | address = keypair.address().decode() 39 | 40 | with pytest.raises(ValueError, match='invalid channel key: bad'): 41 | kin.SDK(secret_key=secret_key, channel_secret_keys=['bad'], 42 | horizon_endpoint_uri=setup.horizon_endpoint_uri, network=setup.network, kin_asset=setup.test_asset) 43 | 44 | # wallet account does not exist 45 | with pytest.raises(kin.AccountNotFoundError): 46 | kin.SDK(secret_key=secret_key, 47 | horizon_endpoint_uri=setup.horizon_endpoint_uri, network=setup.network, kin_asset=setup.test_asset) 48 | 49 | helpers.fund_account(setup, address) 50 | 51 | # wallet account exists but not yet activated 52 | with pytest.raises(kin.AccountNotActivatedError): 53 | kin.SDK(secret_key=secret_key, 54 | horizon_endpoint_uri=setup.horizon_endpoint_uri, network=setup.network, kin_asset=setup.test_asset) 55 | 56 | helpers.trust_asset(setup, secret_key) 57 | 58 | channel_keypair = Keypair.random() 59 | channel_secret_key = channel_keypair.seed() 60 | 61 | # channel account does not exist 62 | with pytest.raises(kin.AccountNotFoundError): 63 | kin.SDK(secret_key=secret_key, channel_secret_keys=[channel_secret_key], 64 | horizon_endpoint_uri=setup.horizon_endpoint_uri, network=setup.network, kin_asset=setup.test_asset) 65 | 66 | # bad Horizon endpoint 67 | with pytest.raises(kin.NetworkError): 68 | kin.SDK(secret_key=secret_key, 69 | horizon_endpoint_uri='bad', network=setup.network, kin_asset=setup.test_asset) 70 | 71 | # no Horizon on endpoint 72 | with pytest.raises(kin.NetworkError): 73 | kin.SDK(secret_key=secret_key, 74 | horizon_endpoint_uri='http://localhost:666', network=setup.network, kin_asset=setup.test_asset) 75 | 76 | 77 | def test_sdk_create_success(setup, test_sdk): 78 | # test defaults 79 | from stellar_base.horizon import HORIZON_LIVE, HORIZON_TEST 80 | sdk = kin.SDK() 81 | assert sdk.horizon.horizon_uri == HORIZON_LIVE 82 | sdk = kin.SDK(network='TESTNET') 83 | assert sdk.horizon.horizon_uri == HORIZON_TEST 84 | 85 | # test test_sdk fixture 86 | assert test_sdk.horizon 87 | assert test_sdk.horizon.horizon_uri == setup.horizon_endpoint_uri 88 | assert test_sdk.network == setup.network 89 | assert test_sdk.base_keypair.verifying_key == setup.sdk_keypair.verifying_key 90 | assert test_sdk.base_keypair.signing_key == setup.sdk_keypair.signing_key 91 | assert test_sdk.channel_manager 92 | 93 | 94 | def test_get_status(setup, test_sdk): 95 | # bad Horizon endpoint 96 | sdk = kin.SDK(horizon_endpoint_uri='bad') 97 | status = sdk.get_status() 98 | assert status['horizon'] 99 | assert status['horizon']['online'] is False 100 | assert status['horizon']['error'].startswith("Invalid URL 'bad': No schema supplied") 101 | 102 | # no Horizon on endpoint 103 | sdk = kin.SDK(horizon_endpoint_uri='http://localhost:666') 104 | status = sdk.get_status() 105 | assert status['horizon'] 106 | assert status['horizon']['online'] is False 107 | assert status['horizon']['error'].find('Connection refused') > 0 108 | 109 | # success 110 | status = test_sdk.get_status() 111 | assert status['network'] == setup.network 112 | assert status['address'] == setup.sdk_keypair.address().decode() 113 | assert status['kin_asset'] 114 | assert status['kin_asset']['code'] == setup.test_asset.code 115 | assert status['kin_asset']['issuer'] == setup.test_asset.issuer 116 | assert status['horizon'] 117 | assert status['horizon']['uri'] == setup.horizon_endpoint_uri 118 | assert status['horizon']['online'] 119 | assert status['horizon']['error'] is None 120 | assert status['channels'] 121 | assert status['channels']['all'] == 1 122 | assert status['channels']['free'] == 1 123 | assert status['transport'] 124 | assert status['transport']['pool_size'] == sdk.horizon.pool_size 125 | assert status['transport']['num_retries'] == sdk.horizon.num_retries 126 | assert status['transport']['request_timeout'] == sdk.horizon.request_timeout 127 | assert status['transport']['retry_statuses'] == sdk.horizon.status_forcelist 128 | assert status['transport']['backoff_factor'] == sdk.horizon.backoff_factor 129 | 130 | 131 | def test_get_address(setup, test_sdk): 132 | assert test_sdk.get_address() == setup.sdk_keypair.address().decode() 133 | 134 | 135 | def test_get_native_balance(test_sdk): 136 | assert test_sdk.get_native_balance() > 9999 137 | 138 | 139 | def test_check_account_exists(setup, test_sdk): 140 | with pytest.raises(ValueError, match='invalid address: bad'): 141 | test_sdk.check_account_exists('bad') 142 | 143 | keypair = Keypair.random() 144 | address = keypair.address().decode() 145 | 146 | assert not test_sdk.check_account_exists(address) 147 | 148 | address = setup.issuer_keypair.address().decode() 149 | assert test_sdk.check_account_exists(address) 150 | 151 | 152 | def test_create_account(test_sdk): 153 | keypair = Keypair.random() 154 | address = keypair.address().decode() 155 | 156 | with pytest.raises(ValueError, match='invalid address: bad'): 157 | test_sdk.create_account('bad') 158 | 159 | # underfunded 160 | with pytest.raises(kin.LowBalanceError) as exc_info: 161 | test_sdk.create_account(address, starting_balance=1000000) 162 | assert exc_info.value.error_code == kin.CreateAccountResultCode.UNDERFUNDED 163 | 164 | # successful 165 | starting_balance = 100 166 | tx_hash = test_sdk.create_account(address, starting_balance=starting_balance, memo_text='foobar') 167 | assert tx_hash 168 | assert test_sdk.check_account_exists(address) 169 | assert test_sdk.get_account_native_balance(address) == starting_balance 170 | 171 | # test get_transaction_data for this transaction 172 | sleep(1) 173 | tx_data = test_sdk.get_transaction_data(tx_hash) 174 | assert tx_data 175 | assert tx_data.hash == tx_hash 176 | assert tx_data.source_account == test_sdk.get_address() 177 | assert tx_data.created_at 178 | assert tx_data.source_account_sequence 179 | assert tx_data.fee_paid == 100 180 | assert tx_data.memo_type == 'text' 181 | assert tx_data.memo == 'foobar' 182 | assert len(tx_data.signatures) == 1 183 | assert len(tx_data.operations) == 1 184 | 185 | op = tx_data.operations[0] 186 | assert op.id 187 | assert op.type == 'create_account' 188 | assert op.asset_code is None 189 | assert op.asset_type is None 190 | assert op.asset_issuer is None 191 | assert op.trustor is None 192 | assert op.trustee is None 193 | assert op.limit is None 194 | assert op.from_address is None 195 | assert op.to_address is None 196 | assert op.amount is None 197 | 198 | with pytest.raises(kin.AccountExistsError) as exc_info: 199 | test_sdk.create_account(address) 200 | assert exc_info.value.error_code == kin.CreateAccountResultCode.ACCOUNT_EXISTS 201 | 202 | 203 | def test_get_account_kin_balance_fail(test_sdk, setup): 204 | with pytest.raises(ValueError, match='invalid address: bad'): 205 | test_sdk.get_account_kin_balance('bad') 206 | 207 | keypair = Keypair.random() 208 | address = keypair.address().decode() 209 | 210 | with pytest.raises(ValueError, match='invalid asset issuer: bad'): 211 | test_sdk._get_account_asset_balance(address, Asset('TMP', 'bad')) 212 | 213 | # account not created yet 214 | with pytest.raises(kin.AccountNotFoundError) as exc_info: 215 | test_sdk.get_account_kin_balance(address) 216 | 217 | assert test_sdk.create_account(address, starting_balance=10) 218 | 219 | with pytest.raises(kin.AccountNotActivatedError) as exc_info: 220 | test_sdk.get_account_kin_balance(address) 221 | 222 | 223 | def test_send_native(test_sdk): 224 | with pytest.raises(ValueError, match='invalid address: bad'): 225 | test_sdk.send_native('bad', 100) 226 | 227 | keypair = Keypair.random() 228 | address = keypair.address().decode() 229 | 230 | with pytest.raises(ValueError, match='amount must be positive'): 231 | test_sdk.send_native(address, 0) 232 | 233 | # account does not exist yet 234 | with pytest.raises(kin.AccountNotFoundError) as exc_info: 235 | test_sdk.send_native(address, 100) 236 | assert exc_info.value.error_code == kin.PaymentResultCode.NO_DESTINATION 237 | 238 | assert test_sdk.create_account(address, starting_balance=100) 239 | 240 | # check underfunded 241 | with pytest.raises(kin.LowBalanceError) as exc_info: 242 | test_sdk.send_native(address, 1000000) 243 | assert exc_info.value.error_code == kin.PaymentResultCode.UNDERFUNDED 244 | 245 | # send and check the resulting balance 246 | tx_hash = test_sdk.send_native(address, 10.123, memo_text='foobar') 247 | assert tx_hash 248 | 249 | assert test_sdk.get_account_native_balance(address) == Decimal('110.123') 250 | 251 | # test get_transaction_data for this transaction 252 | sleep(1) 253 | tx_data = test_sdk.get_transaction_data(tx_hash) 254 | assert tx_data 255 | assert tx_data.hash == tx_hash 256 | assert tx_data.source_account == test_sdk.get_address() 257 | assert tx_data.created_at 258 | assert tx_data.source_account_sequence 259 | assert tx_data.fee_paid == 100 260 | assert tx_data.memo_type == 'text' 261 | assert tx_data.memo == 'foobar' 262 | assert len(tx_data.signatures) == 1 263 | assert len(tx_data.operations) == 1 264 | 265 | op = tx_data.operations[0] 266 | assert op.id 267 | assert op.type == 'payment' 268 | assert op.asset_code is None 269 | assert op.asset_type == 'native' 270 | assert op.asset_issuer is None 271 | assert op.trustor is None 272 | assert op.trustee is None 273 | assert op.limit is None 274 | assert op.from_address == test_sdk.get_address() 275 | assert op.to_address == address 276 | assert op.amount == Decimal('10.123') 277 | 278 | # check several payments in a row 279 | tx_hash1 = test_sdk.send_native(address, 1) 280 | assert tx_hash1 281 | tx_hash2 = test_sdk.send_native(address, 1) 282 | assert tx_hash2 283 | tx_hash3 = test_sdk.send_native(address, 1) 284 | assert tx_hash3 285 | 286 | sleep(1) 287 | tx_data = test_sdk.get_transaction_data(tx_hash1) 288 | assert tx_data.hash == tx_hash1 289 | tx_data = test_sdk.get_transaction_data(tx_hash2) 290 | assert tx_data.hash == tx_hash2 291 | tx_data = test_sdk.get_transaction_data(tx_hash3) 292 | assert tx_data.hash == tx_hash3 293 | 294 | 295 | def test_trust_asset(setup, test_sdk, helpers): 296 | # failures 297 | with pytest.raises(Exception, match='Issuer cannot be null'): 298 | test_sdk._trust_asset(Asset('')) 299 | with pytest.raises(XdrLengthError, match='Asset code must be 12 characters at max.'): 300 | test_sdk._trust_asset(Asset('abcdefghijklmnopqr')) 301 | with pytest.raises(Exception, match='Issuer cannot be null'): 302 | test_sdk._trust_asset(Asset('TMP')) 303 | with pytest.raises(ValueError, match='invalid asset issuer: tmp'): 304 | test_sdk._trust_asset(Asset('TMP', 'tmp')) 305 | 306 | address = Keypair.random().address().decode() 307 | with pytest.raises(kin.RequestError) as exc_info: 308 | test_sdk._trust_asset(Asset('TMP', address)) 309 | assert exc_info.value.error_code == kin.ChangeTrustResultCode.NO_ISSUER 310 | 311 | # success 312 | tx_hash = test_sdk._trust_asset(setup.test_asset, limit=1000, memo_text='foobar') 313 | assert tx_hash 314 | assert test_sdk._check_asset_trusted(test_sdk.get_address(), setup.test_asset) 315 | # TODO: check asset limit 316 | 317 | # test get_transaction_data for this transaction 318 | sleep(1) 319 | tx_data = test_sdk.get_transaction_data(tx_hash) 320 | assert tx_data 321 | assert tx_data.hash == tx_hash 322 | assert tx_data.source_account == test_sdk.get_address() 323 | assert tx_data.created_at 324 | assert tx_data.source_account_sequence 325 | assert tx_data.fee_paid == 100 326 | assert tx_data.memo_type == 'text' 327 | assert tx_data.memo == 'foobar' 328 | assert len(tx_data.signatures) == 1 329 | assert len(tx_data.operations) == 1 330 | 331 | op = tx_data.operations[0] 332 | assert op.id 333 | # assert op.created_at 334 | # assert op.transaction_hash == tx_hash 335 | assert op.type == 'change_trust' 336 | assert op.asset_code == setup.test_asset.code 337 | assert op.asset_type == 'credit_alphanum4' 338 | assert op.asset_issuer == setup.test_asset.issuer 339 | assert op.trustor == test_sdk.get_address() 340 | assert op.trustee == setup.test_asset.issuer 341 | assert op.limit == Decimal('1000') 342 | assert op.from_address is None 343 | assert op.to_address is None 344 | assert op.amount is None 345 | 346 | # finally, fund the sdk account with asset 347 | assert helpers.fund_asset(setup, test_sdk.get_address(), 1000) 348 | assert test_sdk.get_account_kin_balance(test_sdk.get_address()) == Decimal('1000') 349 | 350 | 351 | def test_check_account_activated(setup, test_sdk, helpers): 352 | with pytest.raises(ValueError, match='invalid address: bad'): 353 | test_sdk.check_account_activated('bad') 354 | 355 | keypair = Keypair.random() 356 | address = keypair.address().decode() 357 | 358 | with pytest.raises(ValueError, match='invalid asset issuer: bad'): 359 | test_sdk._check_asset_trusted(address, Asset('TMP', 'bad')) 360 | 361 | with pytest.raises(kin.AccountNotFoundError): 362 | test_sdk.check_account_activated(address) 363 | 364 | assert test_sdk.create_account(address, starting_balance=100) 365 | assert not test_sdk.check_account_activated(address) 366 | 367 | assert helpers.trust_asset(setup, keypair.seed()) 368 | assert test_sdk.check_account_activated(address) 369 | 370 | 371 | def test_send_kin(setup, test_sdk, helpers): 372 | with pytest.raises(ValueError, match='invalid address: bad'): 373 | test_sdk.send_kin('bad', 10) 374 | 375 | keypair = Keypair.random() 376 | address = keypair.address().decode() 377 | 378 | with pytest.raises(ValueError, match='amount must be positive'): 379 | test_sdk.send_kin(address, 0) 380 | 381 | with pytest.raises(ValueError, match='invalid asset issuer: bad'): 382 | test_sdk._send_asset(Asset('TMP', 'bad'), address, 10) 383 | 384 | # account does not exist yet 385 | with pytest.raises(kin.AccountNotFoundError) as exc_info: 386 | test_sdk.send_kin(address, 10) 387 | assert exc_info.value.error_code == kin.PaymentResultCode.NO_DESTINATION 388 | 389 | assert test_sdk.create_account(address, starting_balance=100) 390 | 391 | # no trustline yet 392 | with pytest.raises(kin.AccountNotActivatedError) as exc_info: 393 | test_sdk.send_kin(address, 10) 394 | assert exc_info.value.error_code == kin.PaymentResultCode.NO_TRUST 395 | 396 | # add trustline from the newly created account to the kin issuer 397 | assert helpers.trust_asset(setup, keypair.seed()) 398 | 399 | # send and check the resulting balance 400 | tx_hash = test_sdk.send_kin(address, 10.123, memo_text='foobar') 401 | assert tx_hash 402 | assert test_sdk.get_account_kin_balance(address) == Decimal('10.123') 403 | 404 | # test get_transaction_data for this transaction 405 | sleep(1) 406 | tx_data = test_sdk.get_transaction_data(tx_hash) 407 | assert tx_data 408 | assert tx_data.hash == tx_hash 409 | assert tx_data.source_account == test_sdk.get_address() 410 | assert tx_data.created_at 411 | assert tx_data.source_account_sequence 412 | assert tx_data.fee_paid == 100 413 | assert tx_data.memo_type == 'text' 414 | assert tx_data.memo == 'foobar' 415 | assert len(tx_data.signatures) == 1 416 | assert len(tx_data.operations) == 1 417 | 418 | op = tx_data.operations[0] 419 | assert op.id 420 | assert op.type == 'payment' 421 | assert op.asset_code == setup.test_asset.code 422 | assert op.asset_type == 'credit_alphanum4' 423 | assert op.asset_issuer == setup.test_asset.issuer 424 | assert op.trustor is None 425 | assert op.trustee is None 426 | assert op.limit is None 427 | assert op.from_address == test_sdk.get_address() 428 | assert op.to_address == address 429 | assert op.amount == Decimal('10.123') 430 | 431 | 432 | def test_get_account_data(setup, test_sdk): 433 | with pytest.raises(ValueError, match='invalid address: bad'): 434 | test_sdk.get_account_data('bad') 435 | 436 | address = Keypair.random().address().decode() 437 | with pytest.raises(kin.AccountNotFoundError): 438 | test_sdk.get_account_data(address) 439 | 440 | acc_data = test_sdk.get_account_data(test_sdk.get_address()) 441 | assert acc_data 442 | assert acc_data.id == test_sdk.get_address() 443 | assert acc_data.sequence 444 | assert acc_data.data == {} 445 | 446 | assert acc_data.thresholds 447 | assert acc_data.thresholds.low_threshold == 0 448 | assert acc_data.thresholds.medium_threshold == 0 449 | assert acc_data.thresholds.high_threshold == 0 450 | 451 | assert acc_data.flags 452 | assert not acc_data.flags.auth_revocable 453 | assert not acc_data.flags.auth_required 454 | 455 | assert len(acc_data.balances) == 2 456 | asset_balance = acc_data.balances[0] 457 | native_balance = acc_data.balances[1] 458 | assert asset_balance.balance > 900 459 | assert asset_balance.limit == Decimal('1000') 460 | assert asset_balance.asset_type == 'credit_alphanum4' 461 | assert asset_balance.asset_code == setup.test_asset.code 462 | assert asset_balance.asset_issuer == setup.test_asset.issuer 463 | assert native_balance.balance > 9000 464 | assert native_balance.asset_type == 'native' 465 | 466 | # just to increase test coverage 467 | assert str(acc_data) 468 | 469 | 470 | def test_get_transaction_data_fail(test_sdk): 471 | with pytest.raises(ValueError, match='invalid transaction hash: bad'): 472 | test_sdk.get_transaction_data('bad') 473 | 474 | with pytest.raises(kin.ResourceNotFoundError): 475 | test_sdk.get_transaction_data('deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef') 476 | 477 | 478 | def test_monitor_accounts_transactions_fail(setup, test_sdk): 479 | with pytest.raises(ValueError, match='invalid asset issuer: bad'): 480 | test_sdk._monitor_accounts_asset_transactions(Asset('TMP', 'bad'), None, None) 481 | 482 | with pytest.raises(ValueError, match='no addresses to monitor'): 483 | test_sdk.monitor_accounts_transactions([], None) 484 | 485 | with pytest.raises(ValueError, match='invalid address: bad'): 486 | test_sdk.monitor_accounts_transactions(['bad'], None) 487 | 488 | keypair = Keypair.random() 489 | address = keypair.address().decode() 490 | 491 | with pytest.raises(kin.AccountNotFoundError): 492 | test_sdk.monitor_accounts_transactions([address], None) 493 | 494 | with pytest.raises(kin.AccountNotFoundError): 495 | test_sdk.monitor_accounts_transactions([address], None) 496 | 497 | 498 | def test_monitor_accounts_transactions(setup, test_sdk, helpers): 499 | keypair = Keypair.random() 500 | address = keypair.address().decode() 501 | 502 | tx_hash1 = test_sdk.create_account(address, starting_balance=100, memo_text='create') 503 | assert tx_hash1 504 | 505 | ev = threading.Event() 506 | tx_datas = [] 507 | 508 | def account_tx_callback(addr, tx_data): 509 | assert addr == address 510 | tx_datas.append(tx_data) 511 | if len(tx_datas) == 4: # create/trust/send_asset/send_native 512 | ev.set() 513 | 514 | # start monitoring 515 | sleep(1) 516 | test_sdk.monitor_accounts_transactions([address], account_tx_callback) 517 | 518 | tx_hash2 = helpers.trust_asset(setup, keypair.seed(), memo_text='trust') 519 | assert tx_hash2 520 | 521 | tx_hash3 = test_sdk.send_kin(address, 10, memo_text='send asset') 522 | assert tx_hash3 523 | 524 | tx_hash4 = test_sdk.send_native(address, 1, memo_text='send native') 525 | assert tx_hash4 526 | 527 | # wait until callback gets them all 528 | assert ev.wait(3) 529 | 530 | # check collected transactions 531 | assert tx_datas[0].hash == tx_hash1 532 | assert tx_datas[0].source_account == test_sdk.get_address() 533 | assert tx_datas[0].memo == 'create' 534 | assert tx_datas[0].operations[0].type == 'create_account' 535 | 536 | assert tx_datas[1].hash == tx_hash2 537 | assert tx_datas[1].source_account == address 538 | assert tx_datas[1].memo == 'trust' 539 | assert tx_datas[1].operations[0].type == 'change_trust' 540 | 541 | assert tx_datas[2].hash == tx_hash3 542 | assert tx_datas[2].source_account == test_sdk.get_address() 543 | assert tx_datas[2].memo == 'send asset' 544 | assert tx_datas[2].operations[0].type == 'payment' 545 | assert tx_datas[2].operations[0].asset_code == setup.test_asset.code 546 | 547 | assert tx_datas[3].hash == tx_hash4 548 | assert tx_datas[3].source_account == test_sdk.get_address() 549 | assert tx_datas[3].memo == 'send native' 550 | assert tx_datas[3].operations[0].type == 'payment' 551 | assert tx_datas[3].operations[0].asset_type == 'native' 552 | 553 | 554 | def test_monitor_accounts_kin_payments_single(setup, test_sdk, helpers): 555 | keypair = Keypair.random() 556 | address = keypair.address().decode() 557 | 558 | assert test_sdk.create_account(address, starting_balance=100, memo_text='create') 559 | assert helpers.trust_asset(setup, keypair.seed(), memo_text='trust') 560 | 561 | ev = threading.Event() 562 | tx_datas = [] 563 | 564 | def account_tx_callback(addr, tx_data): 565 | assert addr == address 566 | tx_datas.append(tx_data) 567 | if len(tx_datas) == 2: 568 | ev.set() 569 | 570 | # start monitoring 571 | sleep(1) 572 | test_sdk.monitor_accounts_kin_payments([address], account_tx_callback) 573 | 574 | # pay from sdk to the account 575 | tx_hash1 = test_sdk.send_kin(address, 10) 576 | assert tx_hash1 577 | 578 | # pay from the account back to the sdk 579 | tx_hash2 = helpers.send_asset(setup, keypair.seed(), test_sdk.get_address(), 10) 580 | assert tx_hash2 581 | 582 | # wait until the callback gets them all 583 | assert ev.wait(3) 584 | 585 | # check collected transactions 586 | assert tx_datas[0].hash == tx_hash1 587 | op_data = tx_datas[0].operations[0] 588 | assert op_data.type == 'payment' 589 | assert op_data.asset_code == setup.test_asset.code 590 | assert op_data.asset_issuer == setup.test_asset.issuer 591 | assert op_data.from_address == test_sdk.get_address() 592 | assert op_data.to_address == address 593 | assert op_data.amount == Decimal('10') 594 | 595 | assert tx_datas[1].hash == tx_hash2 596 | op_data = tx_datas[1].operations[0] 597 | assert op_data.type == 'payment' 598 | assert op_data.asset_code == setup.test_asset.code 599 | assert op_data.asset_issuer == setup.test_asset.issuer 600 | assert op_data.from_address == address 601 | assert op_data.to_address == test_sdk.get_address() 602 | assert op_data.amount == Decimal('10') 603 | 604 | 605 | def test_monitor_accounts_kin_payments_multiple(setup, test_sdk, helpers): 606 | keypair1 = Keypair.random() 607 | address1 = keypair1.address().decode() 608 | keypair2 = Keypair.random() 609 | address2 = keypair2.address().decode() 610 | 611 | assert test_sdk.create_account(address1, starting_balance=100) 612 | assert test_sdk.create_account(address2, starting_balance=100) 613 | assert helpers.trust_asset(setup, keypair1.seed()) 614 | assert helpers.trust_asset(setup, keypair2.seed()) 615 | 616 | ev1 = threading.Event() 617 | ev2 = threading.Event() 618 | tx_datas1 = [] 619 | tx_datas2 = [] 620 | 621 | def account_tx_callback(addr, tx_data): 622 | assert addr == address1 or addr == address2 623 | op_data = tx_data.operations[0] 624 | if op_data.to_address == address1: 625 | tx_datas1.append(tx_data) 626 | ev1.set() 627 | elif op_data.to_address == address2: 628 | tx_datas2.append(tx_data) 629 | ev2.set() 630 | 631 | # start monitoring 632 | sleep(1) 633 | test_sdk.monitor_accounts_kin_payments([address1, address2], account_tx_callback) 634 | 635 | # send payments 636 | tx_hash12 = test_sdk.send_kin(address1, 10) 637 | assert tx_hash12 638 | tx_hash22 = test_sdk.send_kin(address2, 10) 639 | assert tx_hash22 640 | 641 | # wait until callback gets them all 642 | assert ev1.wait(3) 643 | assert ev2.wait(3) 644 | 645 | # check collected operations 646 | assert tx_datas1[0].hash == tx_hash12 647 | op_data = tx_datas1[0].operations[0] 648 | assert op_data.type == 'payment' 649 | assert op_data.asset_code == setup.test_asset.code 650 | assert op_data.asset_issuer == setup.test_asset.issuer 651 | assert op_data.from_address == test_sdk.get_address() 652 | assert op_data.to_address == address1 653 | assert op_data.amount == Decimal('10') 654 | 655 | assert tx_datas2[0].hash == tx_hash22 656 | op_data = tx_datas2[0].operations[0] 657 | assert op_data.type == 'payment' 658 | assert op_data.asset_code == setup.test_asset.code 659 | assert op_data.asset_issuer == setup.test_asset.issuer 660 | assert op_data.from_address == test_sdk.get_address() 661 | assert op_data.to_address == address2 662 | assert op_data.amount == Decimal('10') 663 | 664 | 665 | def test_channels(setup, helpers): 666 | # prepare channel accounts 667 | channel_keypairs = [Keypair.random(), Keypair.random(), Keypair.random(), Keypair.random()] 668 | channel_keys = [channel_keypair.seed() for channel_keypair in channel_keypairs] 669 | channel_addresses = [channel_keypair.address().decode() for channel_keypair in channel_keypairs] 670 | for channel_address in channel_addresses: 671 | helpers.fund_account(setup, channel_address) 672 | 673 | # init sdk with these channels 674 | sdk = kin.SDK(secret_key=setup.sdk_keypair.seed(),channel_secret_keys=channel_keys, 675 | horizon_endpoint_uri=setup.horizon_endpoint_uri, network=setup.network, kin_asset=setup.test_asset) 676 | 677 | assert sdk 678 | assert sdk.channel_manager 679 | assert sdk.channel_manager.channel_builders.qsize() == len(channel_keypairs) 680 | 681 | thread_ex = [] 682 | 683 | def channel_worker(thread_ex_holder): 684 | try: 685 | # create an account using a channel 686 | address = Keypair.random().address().decode() 687 | tx_hash1 = sdk.create_account(address, starting_balance=100) 688 | assert tx_hash1 689 | # send lumens 690 | tx_hash2 = sdk.send_native(address, 1) 691 | assert tx_hash2 692 | # send more lumens 693 | tx_hash3 = sdk.send_native(address, 1) 694 | assert tx_hash3 695 | 696 | sleep(1) 697 | 698 | # check transactions 699 | tx_data = sdk.get_transaction_data(tx_hash1) 700 | assert tx_data 701 | # transaction envelope source is some channel account 702 | assert tx_data.source_account in channel_addresses 703 | # operation source is the base account 704 | assert tx_data.operations[0].source_account == sdk.get_address() 705 | 706 | tx_data = sdk.get_transaction_data(tx_hash2) 707 | assert tx_data 708 | assert tx_data.source_account in channel_addresses 709 | assert tx_data.operations[0].source_account == sdk.get_address() 710 | 711 | tx_data = sdk.get_transaction_data(tx_hash3) 712 | assert tx_data 713 | assert tx_data.source_account in channel_addresses 714 | assert tx_data.operations[0].source_account == sdk.get_address() 715 | except Exception as e: 716 | thread_ex_holder.append(e) 717 | 718 | # now issue parallel transactions 719 | threads = [] 720 | for channel_keypair in channel_keypairs: 721 | t = threading.Thread(target=channel_worker, args=(thread_ex,)) 722 | threads.append(t) 723 | for t in threads: 724 | t.start() 725 | 726 | # wait for all to finish 727 | for t in threads: 728 | t.join() 729 | 730 | # check thread errors 731 | assert not thread_ex 732 | 733 | -------------------------------------------------------------------------------- /test/test_utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from kin.stellar.utils import * 4 | 5 | 6 | def test_is_valid_address(): 7 | assert not is_valid_address('bad') 8 | assert not is_valid_address('deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef') 9 | address = 'GDQNZRZAU5D5MYSMQX7VNANEVXF6IEIYKAP3TK6WX5HYBV6FGATJ462F' 10 | assert not is_valid_address(address.replace('M', 'N')) 11 | assert is_valid_address(address) 12 | 13 | 14 | def test_is_valid_secret_key(): 15 | assert not is_valid_secret_key('bad') 16 | assert not is_valid_secret_key('deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef') 17 | key = 'SBQBGUY2RKHDTW4QYH6366QO2U7BHY7F6HFMA42VQIM6QN5X7BGUIRS5' 18 | assert not is_valid_secret_key(key.replace('M', 'N')) 19 | assert is_valid_secret_key(key) 20 | 21 | 22 | def test_is_valid_transaction_hash(): 23 | assert not is_valid_transaction_hash('bad') 24 | assert is_valid_transaction_hash('c2a9d905a728ae918bf50058548f2421463ae09e1302be8e5b4b882c81c2edb8') 25 | --------------------------------------------------------------------------------