diff --git a/xchainpy/.gitignore b/xchainpy/.gitignore new file mode 100644 index 0000000..72b5396 --- /dev/null +++ b/xchainpy/.gitignore @@ -0,0 +1 @@ +myTests.py diff --git a/xchainpy/xchainpy_fantom/LICENSE b/xchainpy/xchainpy_fantom/LICENSE new file mode 100644 index 0000000..3a5b2a0 --- /dev/null +++ b/xchainpy/xchainpy_fantom/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Merlin Lindsay + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/xchainpy/xchainpy_fantom/README.md b/xchainpy/xchainpy_fantom/README.md new file mode 100644 index 0000000..2fa00d6 --- /dev/null +++ b/xchainpy/xchainpy_fantom/README.md @@ -0,0 +1,24 @@ +# `xchainpy/xchainpy_fantom` + +Fantom Module for XChainPy Clients + +## Environment +tested with Python Virtual Environment 3.8 3.9 + +## Installation +```angular2html +python3 setup.py install +``` + +## Service Providers +- https://rpc.ftm.tools/ was used to interact with fantom blockchain +- Note this is HTTPProvider, not WebsocketProvider (see client.py) +- - If interaction with ``non-ERC20 token`` is needed, head to https://ftmscan.com/ to get your etherscan token. + +## Initialization of Client + +ToDo + +## Tests + +ToDo \ No newline at end of file diff --git a/xchainpy/xchainpy_fantom/pyproject.toml b/xchainpy/xchainpy_fantom/pyproject.toml new file mode 100644 index 0000000..39a95f5 --- /dev/null +++ b/xchainpy/xchainpy_fantom/pyproject.toml @@ -0,0 +1,6 @@ +[build-system] +requires = [ + "setuptools>=42", + "wheel" +] +build-backend = "setuptools.build_meta" \ No newline at end of file diff --git a/xchainpy/xchainpy_fantom/requirements.txt b/xchainpy/xchainpy_fantom/requirements.txt new file mode 100644 index 0000000..1b571a8 --- /dev/null +++ b/xchainpy/xchainpy_fantom/requirements.txt @@ -0,0 +1,5 @@ +web3>=5.16.0 +websockets>=8.1 +xchainpy_client>=0.1.3 +xchainpy_crypto>=0.1.3 +requests>=2.25.1 \ No newline at end of file diff --git a/xchainpy/xchainpy_fantom/setup.cfg b/xchainpy/xchainpy_fantom/setup.cfg new file mode 100644 index 0000000..224a779 --- /dev/null +++ b/xchainpy/xchainpy_fantom/setup.cfg @@ -0,0 +1,2 @@ +[metadata] +description-file = README.md \ No newline at end of file diff --git a/xchainpy/xchainpy_fantom/tests/__init__.py b/xchainpy/xchainpy_fantom/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/xchainpy/xchainpy_fantom/xchainpy_fantom/__init__.py b/xchainpy/xchainpy_fantom/xchainpy_fantom/__init__.py new file mode 100644 index 0000000..2a94d41 --- /dev/null +++ b/xchainpy/xchainpy_fantom/xchainpy_fantom/__init__.py @@ -0,0 +1,5 @@ +"""Fantom Module for XChainPY Clients +.. moduleauthor:: Merlin-721 +""" + +__version__ = '0.2.3' \ No newline at end of file diff --git a/xchainpy/xchainpy_fantom/xchainpy_fantom/client.py b/xchainpy/xchainpy_fantom/xchainpy_fantom/client.py new file mode 100644 index 0000000..f8e492a --- /dev/null +++ b/xchainpy/xchainpy_fantom/xchainpy_fantom/client.py @@ -0,0 +1,348 @@ +import asyncio +import json +import os +import requests +from web3 import Web3, WebsocketProvider, HTTPProvider, Account +from web3.gas_strategies.time_based import slow_gas_price_strategy, medium_gas_price_strategy, fast_gas_price_strategy +from xchainpy_fantom.models.asset import Asset +from xchainpy_fantom.models.client_types import FantomClientParams +from xchainpy_client.base_xchain_client import BaseXChainClient +from xchainpy_crypto import crypto +from xchainpy_util.chain import Chain + +class IFantomClient: + def is_web3_connected(self): + pass + + async def get_abi(self, contract_address): + pass + + async def get_contract(self, contract_address, erc20=True): + pass + + async def read_contract(self, contract_address, func_to_call, *args, erc20=True): + pass + + async def write_contract(self, contract_address, func_to_call, *args, erc20=True, gas_limit=1000000, gas_price=None, nonce=None): + pass + + def set_gas_strategy(self, gas_strategy): + pass + + async def transfer(self, asset: Asset, amount, recipient, gas_limit=1000000, gas_price=None): + pass + + def get_transaction_data(self, tx_id): + pass + + def get_transaction_receipt(self, tx_id): + pass + + async def get_balance(self, asset: Asset=None, address=None): + pass + +class Client(BaseXChainClient, IFantomClient): + wss_provider = ftmscan_token = "" + script_dir = os.path.dirname(__file__) + with open(os.path.join(script_dir, "resources/ERC20"), 'r') as f: + erc20_abi = json.loads(f.read())["abi"] + gas_strategy = "medium" + gas_price = None + w3 = account = None + + def __init__(self, params: FantomClientParams): + BaseXChainClient.__init__(self, Chain.Fantom, params) + os.makedirs(os.path.join(self.script_dir, f'resources/{params.network}'), exist_ok=True) + self.set_wss_provider(params.wss_provider) + self.set_ftmscan_token(params.ftmscan_token) + Account.enable_unaudited_hdwallet_features() + self.account = self.w3.eth.account.from_mnemonic(mnemonic=params.phrase) + + def set_wss_provider(self, wss_provider: str): + self.w3 = Web3(HTTPProvider(wss_provider)) + if not self.is_web3_connected(): + raise Exception("websocket provider error") + + def set_ftmscan_token(self, ftmscan_token: str): + self.ftmscan_token = ftmscan_token + + def is_web3_connected(self): + """Check Web3 connectivity + Returns: + bool + """ + return self.w3.isConnected() + + def purge_client(self): + """Purge Client + + Delete Account + + Returns: + void + + """ + self.w3 = self.account = None + + def get_explorer_url(self) -> str: + """Get explorer url + :returns: the explorer url for binance chain based on the network + """ + return 'https://testnet.ftmscan.com' if self.network == 'testnet' else 'https://ftmscan.com' + + def get_explorer_address_url(self, address: str) -> str: + """Get the explorer url for the given address + :param address: address + :type address: str + :returns: The explorer url for the given address based on the network + """ + return f'{self.get_explorer_url()}/address/{address}' + + def get_explorer_tx_url(self, tx_id: str) -> str: + """Get the explorer url for the given transaction id + :param tx_id: tx_id + :type tx_id: str + :returns: The explorer url for the given transaction id based on the network + """ + return f'{self.get_explorer_url()}/tx/{tx_id}' + + def get_account(self): + return self.account + + def get_address(self, index=0): + """Get current wallet address + + Returns: + current wallet address + + """ + if index == 0: + return self.account.address + + def validate_address(self, address: str): + """Check address validity + + Args: + address: fantom address + + Returns: + bool + + """ + return self.w3.isAddress(address) + + async def get_abi(self, contract_address): + """Get abi description of a non ERC-20 contract + + Args: + contract_address: contract address + + Returns: + abi description[json] + + """ + path = os.path.join(self.script_dir, f'resources/{self.network}/{contract_address}') + if os.path.exists(path): + with open(path, 'r') as f: + return json.loads(f.read()) + else: + resource_path = os.path.join(self.script_dir, f'resources/{self.network}') + os.makedirs(resource_path, exist_ok=True) + if not self.ftmscan_token: + raise Exception("undefined ftm api token") + if self.network == 'mainnet': + url = f'https://api.ftmscan.com/api?module=contract&action=getabi&address={contract_address}&apikey={self.ftmscan_token}' + else: + url = f'https://api-testnet.ftmscan.com/api?module=contract&action=getabi&address={contract_address}&apikey={self.ftmscan_token}' + headers = {'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/50.0.2661.102 Safari/537.36'} + r = requests.get(url, headers=headers).json() + if r["status"] != '1': + raise Exception("error getting abi file") + with open(path, 'w+') as o: + o.write(r["result"]) + return json.loads(r["result"]) + + async def get_contract(self, contract_address, erc20=True): + """Get Contract object of given address + if you are calling non-generic functions you have to pass in erc20=false + + Args: + contract_address: fantom contract address + erc20: True if contract = ERC-20, False otherwise + + Returns: + web3 contract object + + """ + abi = self.erc20_abi + if not erc20: + abi = await self.get_abi(contract_address) + return self.w3.eth.contract(abi=abi, address=contract_address) + + async def get_balance(self, asset=None, address=None): + """Get the balance of a erc-20 token + + Args: + asset: asset object, if None, return balance of fantom. (optional) + address: By default, it will return the balance of the current wallet. (optional) + + Returns: + Balance of the address + + """ + if not address: + address = self.get_address() + if not asset: + return self.w3.fromWei(self.w3.eth.get_balance(address), 'ether') + else: + assert asset.contract, "asset contract address not set" + contract = await self.get_contract(asset.contract) + decimal = contract.functions.decimals().call() + return contract.functions.balanceOf(address).call() / 10 ** decimal + + def set_gas_strategy(self, gas_strategy) -> None: + """Set Gas fee calculation parameter + + fast: transaction mined within 60 seconds + medium: transaction mined within 5 minutes + slow: transaction mined within 1 hour + + Args: + gas_strategy: ['fast', 'medium', 'slow'] + + Returns: + void + """ + if gas_strategy == "fast": + self.w3.eth.set_gas_price_strategy(fast_gas_price_strategy) + elif gas_strategy == 'medium': + self.w3.eth.set_gas_price_strategy(medium_gas_price_strategy) + elif gas_strategy == 'slow': + self.w3.eth.set_gas_price_strategy(slow_gas_price_strategy) + else: + raise Exception("invalid gas strategy") + self.gas_price = self.w3.eth.generate_gas_price() + + def get_fees(self): + """Return Gas price using gas_strategy + + Returns: + gas price in Wei + + """ + return self.gas_price + + async def transfer(self, asset: Asset, amount, recipient, gas_limit=1000000, gas_price=None): + """Transfer ERC20 token with previous configured gas_price + + Args: + recipient: recipient address + amount: amount in ether or in alt coin + gas_limit: gas limit using gas price + gas_price: gas price in wei + contract_address: for assets other than ether + + Returns: + tx_hash(str) + + """ + if not gas_price: + gas_price = self.get_fees() + if not gas_price: + raise Exception("gas_price not set") + nonce = self.w3.eth.get_transaction_count(self.get_address()) + if asset.symbol == 'FTM': + tx = { + 'nonce': nonce, + 'to': recipient, + 'value': self.w3.toWei(amount, 'ether'), + 'gas': gas_limit, + 'gasPrice': gas_price, + } + signed_tx = self.account.sign_transaction(tx) + tx_hash = self.w3.eth.send_raw_transaction(signed_tx.rawTransaction) + return tx_hash + else: + tx = { + 'nonce': nonce, + 'gas': gas_limit, + 'gasPrice': gas_price, + } + token_contract = await self.get_contract(contract_address=asset.contract) + decimal = token_contract.functions.decimals().call() + raw_tx = token_contract.functions.transfer(recipient, amount*10**decimal).buildTransaction(tx) + signed_tx = self.account.sign_transaction(raw_tx) + tx_hash = self.w3.eth.send_raw_transaction(signed_tx.rawTransaction) + receipt = self.w3.eth.wait_for_transaction_receipt(tx_hash) + return receipt + + async def read_contract(self, contract_address, func_to_call, *args, erc20=True): + contract = await self.get_contract(contract_address=contract_address, erc20=erc20) + return contract.functions[func_to_call](*args).call() + + async def write_contract(self, contract_address, func_to_call, *args, erc20=True, gas_limit=1000000, gas_price=None, nonce=None, ftm_to_be_sent=0): + """Write to any contract with any argument, specify whether it's ERC20 + + Args: + contract_address: contract address to interact with + func_to_call: name of contract function to call + erc20: True if contract = ERC-20, False otherwise + gas_limit: 1000000 by default + gas_price: gas price + **kwargs: arguments for func_to_call + nonce: provide nonce for faster execution + ftm_to_be_sent: in case fantom needed to be sent + + Returns: + + """ + if not nonce: + nonce = self.w3.eth.get_transaction_count(self.get_address()) + if not gas_price: + gas_price = self.gas_price + if not gas_price: + raise Exception("provide gas price or call set_gas_strategy()") + if ftm_to_be_sent != 0: + tx = { + 'nonce': nonce, + 'value': self.w3.toWei(ftm_to_be_sent, 'ether'), + 'gas': gas_limit, + 'gasPrice': gas_price, + } + else: + tx = { + 'nonce': nonce, + 'gas': gas_limit, + 'gasPrice': gas_price, + } + smart_contract = await self.get_contract(contract_address=contract_address, erc20=erc20) + contract_func = smart_contract.functions[func_to_call] + raw_tx = contract_func(*args).buildTransaction(tx) + signed_tx = self.account.sign_transaction(raw_tx) + tx_hash = self.w3.eth.send_raw_transaction(signed_tx.rawTransaction) + receipt = self.w3.eth.wait_for_transaction_receipt(transaction_hash=tx_hash, timeout=600) + return receipt + + def get_transaction_data(self, tx_id): + """ + Args: + tx_id: + Returns: + AttributeDict({ + 'blockHash': '0x4e3a3754410177e6937ef1f84bba68ea139e8d1a2258c5f85db9f1cd715a1bdd', + 'blockNumber': 46147, + 'from': '0xA1E4380A3B1f749673E270229993eE55F35663b4', + 'gas': 21000, + 'gasPrice': 50000000000000, + 'hash': '0x5c504ed432cb51138bcf09aa5e8a410dd4a1e204ef84bfed1be16dfba1b22060', + 'input': '0x', + 'nonce': 0, + 'to': '0x5DF9B87991262F6BA471F09758CDE1c0FC1De734', + 'transactionIndex': 0, + 'value': 31337, + }) + """ + return self.w3.eth.get_transaction(tx_id) + + def get_transaction_receipt(self, tx_id): + return self.w3.eth.get_transaction_receipt(tx_id) diff --git a/xchainpy/xchainpy_fantom/xchainpy_fantom/crypto.py b/xchainpy/xchainpy_fantom/xchainpy_fantom/crypto.py new file mode 100644 index 0000000..2472bf4 --- /dev/null +++ b/xchainpy/xchainpy_fantom/xchainpy_fantom/crypto.py @@ -0,0 +1,20 @@ +from mnemonic import Mnemonic + + +def generate_mnemonic(language, filename): + """Generate test_suite using user-specified language and save to local file + + :param language: language of test_suite words + :type language: str + :param filename: filename to save test_suite + :type filename: str + :returns: test_suite + """ + words = Mnemonic(language).generate(strength=256) + output = open(filename, "w+") + output.write(words) + output.close() + return words + + +generate_mnemonic("english", "resources/mainnet/mnemonic") \ No newline at end of file diff --git a/xchainpy/xchainpy_fantom/xchainpy_fantom/models/__init__.py b/xchainpy/xchainpy_fantom/xchainpy_fantom/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/xchainpy/xchainpy_fantom/xchainpy_fantom/models/asset.py b/xchainpy/xchainpy_fantom/xchainpy_fantom/models/asset.py new file mode 100644 index 0000000..b0f19f2 --- /dev/null +++ b/xchainpy/xchainpy_fantom/xchainpy_fantom/models/asset.py @@ -0,0 +1,51 @@ +from xchainpy_util.asset import Asset as base +from xchainpy_util.chain import is_chain + + +class Asset(base): + _contract = '' + + def __init__(self, chain, symbol, ticker='', contract=''): + """ + :param chain: chain type + :type chain: str + :param symbol: symbol name + :type symbol: str + :param ticker: is the symbol or the contract address of the token + :type ticker: str + """ + + if is_chain(chain): + self._chain = chain + else: + raise Exception('the chain is invalid') + self._symbol = symbol + if not ticker: + if '-' in symbol: + self._ticker = symbol[0:symbol.index('-')] + else: + self._ticker = symbol + else: + self._ticker = ticker + if not contract: + if ':' in symbol: + self._contract = symbol[symbol.index(':')+1:] + else: + self._contract = symbol + else: + self._contract = contract + + def __str__(self): + """Get an asset from a string + + :returns: the asset (BNB.BNB or BNB.RUNE) + """ + return f'{self.chain}.{self.symbol}:{self.contract}' + + @property + def contract(self): + return self._contract + + @contract.setter + def contract(self, contract): + self._contract = contract diff --git a/xchainpy/xchainpy_fantom/xchainpy_fantom/models/client_types.py b/xchainpy/xchainpy_fantom/xchainpy_fantom/models/client_types.py new file mode 100644 index 0000000..80c97eb --- /dev/null +++ b/xchainpy/xchainpy_fantom/xchainpy_fantom/models/client_types.py @@ -0,0 +1,40 @@ +from xchainpy_client.models.tx_types import TxParams +from xchainpy_client.models.types import Network, RootDerivationPaths, XChainClientParams +from xchainpy_util.asset import Asset + + +class FantomClientParams(XChainClientParams): + def __init__(self, wss_provider: str, ftmscan_token: str, network: Network = Network.Testnet, phrase=None, + root_derivation_paths: RootDerivationPaths = RootDerivationPaths( + mainnet="m/44’/60’/0’/0", + # testnet path + testnet="m/44’/1’/0’/0"),): + """ + Args: + wss_provider: websocket provider, this is how web3 talks to blockchain + ftmscan_token: ftmscan api token, to download non-erc20 token abi + network: network, only testnet is supported as testnet + phrase: phrase + root_derivation_paths: root_derivation_paths + """ + super().__init__(network, phrase, root_derivation_paths) + self._wss_provider = wss_provider + self._ftmscan_token = ftmscan_token + + @property + def wss_provider(self): + return self._wss_provider + + @wss_provider.setter + def wss_provider(self, wss_provider): + self._wss_provider = wss_provider + + @property + def ftmscan_token(self): + return self._ftmscan_token + + @ftmscan_token.setter + def ftmscan_token(self, ftmscan_token): + self._ftmscan_token = ftmscan_token + + diff --git a/xchainpy/xchainpy_fantom/xchainpy_fantom/resources/ERC20 b/xchainpy/xchainpy_fantom/xchainpy_fantom/resources/ERC20 new file mode 100644 index 0000000..02a4e94 --- /dev/null +++ b/xchainpy/xchainpy_fantom/xchainpy_fantom/resources/ERC20 @@ -0,0 +1,244 @@ +{ + "contractName": "iERC20", + "abi": [ + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Approval", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Transfer", + "type": "event" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_owner", + "type": "address" + }, + { + "internalType": "address", + "name": "spender", + "type": "address" + } + ], + "name": "allowance", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "approve", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "decimals", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getOwner", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "name", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "symbol", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "totalSupply", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "transfer", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "sender", + "type": "address" + }, + { + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "transferFrom", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + } + ], + "bytecode": "0x", + "deployedBytecode": "0x", + "linkReferences": {}, + "deployedLinkReferences": {} +} diff --git a/xchainpy/xchainpy_fantom/xchainpy_fantom/resources/fantom_api b/xchainpy/xchainpy_fantom/xchainpy_fantom/resources/fantom_api new file mode 100644 index 0000000..3d952a5 --- /dev/null +++ b/xchainpy/xchainpy_fantom/xchainpy_fantom/resources/fantom_api @@ -0,0 +1 @@ +7THXSACYN3TSNJWZB648H69K6QFW9FNM79 \ No newline at end of file diff --git a/xchainpy/xchainpy_fantom/xchainpy_fantom/resources/mainnet/mnemonic b/xchainpy/xchainpy_fantom/xchainpy_fantom/resources/mainnet/mnemonic new file mode 100644 index 0000000..e69de29 diff --git a/xchainpy/xchainpy_fantom/xchainpy_fantom/utils.py b/xchainpy/xchainpy_fantom/xchainpy_fantom/utils.py new file mode 100644 index 0000000..e69de29