diff --git a/.gitignore b/.gitignore index e671563..227cdfd 100644 --- a/.gitignore +++ b/.gitignore @@ -103,3 +103,4 @@ data/ # mypy .mypy_cache/ .idea +.history/ diff --git a/.gitmodules b/.gitmodules index 09dcc72..9e0684b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -3,4 +3,4 @@ url = https://github.com/polkascan/py-substrate-interface.git [submodule "py-scale-codec"] path = py-scale-codec - url = https://github.com/polkascan/py-scale-codec.git + url = https://github.com/ProChain/py-scale-codec.git diff --git a/app/main.py b/app/main.py index e8051d1..ed5ed73 100644 --- a/app/main.py +++ b/app/main.py @@ -79,6 +79,12 @@ app.add_route('/networkstats/{network_id}', polkascan.NetworkStatisticsResource()) app.add_route('/balances/transfer', polkascan.BalanceTransferListResource()) app.add_route('/balances/transfer/{item_id}', polkascan.BalanceTransferDetailResource()) +app.add_route('/transfer/{did}', polkascan.TransferListResource()) +app.add_route('/did', polkascan.DidListResource()) +app.add_route('/did/{item_id}', polkascan.DidDetailResource()) +app.add_route('/did/social_account/{item_id}', polkascan.DidDetailBySocialAccountResource()) +app.add_route('/did/members/{did_hash}', polkascan.DidMembersResource()) +app.add_route('/did/invite_ranking',polkascan.DidInviteRanking()) app.add_route('/account', polkascan.AccountResource()) app.add_route('/account/{item_id}', polkascan.AccountDetailResource()) app.add_route('/accountindex', polkascan.AccountIndexListResource()) diff --git a/app/models/data.py b/app/models/data.py index df4684e..0ce4d99 100644 --- a/app/models/data.py +++ b/app/models/data.py @@ -873,3 +873,36 @@ class RuntimeType(BaseModel): spec_version = sa.Column(sa.Integer(), nullable=False) type_string = sa.Column(sa.String(255)) decoder_class = sa.Column(sa.String(255), nullable=True) + +class Transfer(BaseModel): + __tablename__ = 'data_transfer' + + block_id = sa.Column(sa.Integer(), primary_key=True) + block = relationship(Block, foreign_keys=[block_id], primaryjoin=block_id == Block.id) + + event_idx = sa.Column(sa.Integer(), primary_key=True) + + extrinsic_idx = sa.Column(sa.Integer()) + from_did = sa.Column(sa.String(44),index = True) + from_account_id = sa.Column(sa.String(64),index=True) + to_did = sa.Column(sa.String(44),index = True) + to_account_id = sa.Column(sa.String(64),index=True) + balance = sa.Column(sa.Numeric(precision=65, scale=0), nullable=False) + fee = sa.Column(sa.Numeric(precision=18, scale=0), nullable=False) + datetime = sa.Column(sa.DateTime(timezone=True)) + + + def serialize_id(self): + return '{}-{}'.format(self.block_id, self.event_idx) + +class Did(BaseModel): + __tablename__ = 'data_did' + + did = sa.Column(sa.String(50), primary_key=True) + address = sa.Column(sa.String(48),index=True) + superior = sa.Column(sa.String(66),index=True) + did_hash = sa.Column(sa.String(66),index=True) + creator = sa.Column(sa.String(50)) + social_account_hash = sa.Column(sa.String(66),index =True) + def serialize_id(self): + return self.did \ No newline at end of file diff --git a/app/resources/base.py b/app/resources/base.py index 768572d..ac28415 100644 --- a/app/resources/base.py +++ b/app/resources/base.py @@ -25,7 +25,7 @@ from sqlalchemy.orm import Session from app.settings import MAX_RESOURCE_PAGE_SIZE, DOGPILE_CACHE_SETTINGS - +from app.models.data import Did class BaseResource(object): @@ -46,6 +46,33 @@ def get_meta(self): def serialize_item(self, item): return item.serialize() + #["address","address2"] + def convert_to_did_items(self): + return [] + + def convert_to_did(self,data): + items = self.convert_to_did_items() + if items and len(items)>0: + if isinstance(data,list): + address = [] + for row in data: + for item in items: + address.append(row['attributes'][item]) + alldid = Did.query(self.session).filter(Did.address.in_(address)).all() + did_map = dict([(did.address,did.did) for did in alldid]) + for row in data: + for item in items: + if row['attributes'][item] in did_map: + row['attributes'][item+'_source'] = row['attributes'][item] + row['attributes'][item] = did_map[row['attributes'][item]] + else: + for item in items: + did = Did.query(self.session).filter_by(address=data['attributes'][item]).first() + if did: + data['attributes'][item+'_source'] = data['attributes'][item] + data['attributes'][item] = did.did + return data + def process_get_response(self, req, resp, **kwargs): return { 'status': falcon.HTTP_200, @@ -54,7 +81,7 @@ def process_get_response(self, req, resp, **kwargs): } def get_jsonapi_response(self, data, meta=None, errors=None, links=None, relationships=None, included=None): - + data = self.convert_to_did(data) result = { 'meta': { "authors": [ @@ -67,7 +94,6 @@ def get_jsonapi_response(self, data, meta=None, errors=None, links=None, relatio "data": data, "links": {} } - if meta: result['meta'].update(meta) @@ -135,7 +161,8 @@ def process_get_response(self, req, resp, **kwargs): items = self.get_query() items = self.apply_filters(items, req.params) items = self.apply_paging(items, req.params) - + print(len(items)) + print(items) return { 'status': falcon.HTTP_200, 'media': self.get_jsonapi_response( @@ -145,7 +172,52 @@ def process_get_response(self, req, resp, **kwargs): 'cacheable': True } +class JSONAPIListResource2(JSONAPIResource, ABC): + + cache_expiration_time = DOGPILE_CACHE_SETTINGS['default_list_cache_expiration_time'] + + def get_item_url_name(self): + return 'item_id' + + @abstractmethod + def get_query(self,item_id): + raise NotImplementedError() + + def apply_paging(self, query, params): + page = int(params.get('page[number]', 1)) - 1 + page_size = min(int(params.get('page[size]', 25)), MAX_RESOURCE_PAGE_SIZE) + return query[page * page_size: page * page_size + page_size] + def has_total(self): + return False + + def process_get_response(self, req, resp, **kwargs): + items = self.get_query(kwargs.get(self.get_item_url_name())) + items = self.apply_filters(items, req.params) + total = -1 + if self.has_total(): + total = items.count() + items = self.apply_paging(items, req.params) + if total >= 0: + meta = self.get_meta() + meta['total'] = total + return { + 'status': falcon.HTTP_200, + 'media': self.get_jsonapi_response( + data = [self.serialize_item(item) for item in items], + meta = meta + ), + 'cacheable': True + } + else: + return { + 'status': falcon.HTTP_200, + 'media': self.get_jsonapi_response( + data = [self.serialize_item(item) for item in items], + meta=self.get_meta() + ), + 'cacheable': True + } class JSONAPIDetailResource(JSONAPIResource, ABC): cache_expiration_time = DOGPILE_CACHE_SETTINGS['default_detail_cache_expiration_time'] @@ -175,7 +247,7 @@ def process_get_response(self, req, resp, **kwargs): response = { 'status': falcon.HTTP_200, 'media': self.get_jsonapi_response( - data=self.serialize_item(item), + data= self.serialize_item(item), relationships=self.get_relationships(req.params.get('include') or [], item), meta=self.get_meta() ), diff --git a/app/resources/polkascan.py b/app/resources/polkascan.py index bb2dd71..ecd5bf8 100644 --- a/app/resources/polkascan.py +++ b/app/resources/polkascan.py @@ -21,17 +21,20 @@ import falcon from dogpile.cache.api import NO_VALUE from sqlalchemy import func - +from sqlalchemy import or_ from app.models.data import Block, Extrinsic, Event, RuntimeCall, RuntimeEvent, Runtime, RuntimeModule, \ RuntimeCallParam, RuntimeEventAttribute, RuntimeType, RuntimeStorage, Account, Session, DemocracyProposal, Contract, \ BlockTotal, SessionValidator, Log, DemocracyReferendum, AccountIndex, RuntimeConstant, SessionNominator, \ - DemocracyVote, CouncilMotion, CouncilVote, TechCommProposal, TechCommProposalVote, TreasuryProposal -from app.resources.base import JSONAPIResource, JSONAPIListResource, JSONAPIDetailResource -from app.settings import SUBSTRATE_RPC_URL, SUBSTRATE_METADATA_VERSION, SUBSTRATE_ADDRESS_TYPE, TYPE_REGISTRY + DemocracyVote, CouncilMotion, CouncilVote, TechCommProposal, TechCommProposalVote, TreasuryProposal, Transfer, Did +from app.resources.base import JSONAPIResource, JSONAPIListResource, JSONAPIListResource2,JSONAPIDetailResource +from app.settings import SUBSTRATE_RPC_URL, SUBSTRATE_METADATA_VERSION, SUBSTRATE_ADDRESS_TYPE, TYPE_REGISTRY_PATH from app.utils.ss58 import ss58_decode, ss58_encode from scalecodec.base import RuntimeConfiguration -from scalecodec.type_registry import load_type_registry_preset +from scalecodec.type_registry import load_type_registry_preset,load_type_registry_file from substrateinterface import SubstrateInterface +import json +import decimal +import datetime class BlockDetailsResource(JSONAPIDetailResource): @@ -113,13 +116,19 @@ def apply_filters(self, query, params): if params.get('filter[address]')[0:2] == '0x': account_id = params.get('filter[address]')[2:] + elif params.get('filter[address]')[0:3] == 'did': + did = Did.query(self.session).filter_by(did=params.get('filter[address]')).first() + account_id = ss58_decode(did.address, SUBSTRATE_ADDRESS_TYPE) else: - account_id = ss58_decode(params.get('filter[address]'), SUBSTRATE_ADDRESS_TYPE) + account_id = ss58_decode(params.get( + 'filter[address]'), SUBSTRATE_ADDRESS_TYPE) query = query.filter_by(address=account_id) return query - + + def convert_to_did_items(self): + return ["address"] class ExtrinsicDetailResource(JSONAPIDetailResource): @@ -129,7 +138,8 @@ def get_item_url_name(self): def get_item(self, item_id): if item_id[0:2] == '0x': - extrinsic = Extrinsic.query(self.session).filter_by(extrinsic_hash=item_id[2:]).first() + extrinsic = Extrinsic.query(self.session).filter_by( + extrinsic_hash=item_id[2:]).first() else: extrinsic = Extrinsic.query(self.session).get(item_id.split('-')) @@ -148,7 +158,8 @@ def serialize_item(self, item): return data - + def convert_to_did_items(self): + return ["address"] class EventsListResource(JSONAPIListResource): def apply_filters(self, query, params): @@ -220,7 +231,8 @@ def on_get(self, req, resp, network_id=None): if response is NO_VALUE: - best_block = BlockTotal.query(self.session).filter_by(id=self.session.query(func.max(BlockTotal.id)).one()[0]).first() + best_block = BlockTotal.query(self.session).filter_by( + id=self.session.query(func.max(BlockTotal.id)).one()[0]).first() if best_block: response = self.get_jsonapi_response( data={ @@ -280,10 +292,12 @@ def serialize_item(self, item): 'destination': ss58_encode(item.attributes[1]['value'].replace('0x', ''), SUBSTRATE_ADDRESS_TYPE), 'destination_id': item.attributes[1]['value'].replace('0x', ''), 'value': item.attributes[2]['value'], - 'fee': item.attributes[3]['value'] + 'fee': 0 } } - + def convert_to_did_items(self): + return ['sender','destination'] + class BalanceTransferDetailResource(JSONAPIDetailResource): @@ -306,6 +320,74 @@ def serialize_item(self, item): } } + def convert_to_did_items(self): + return ['sender','destination'] + +class TransferListResource(JSONAPIListResource2): + + def get_item_url_name(self): + return 'did' + + def get_query(self,did): + return Transfer.query(self.session).filter(or_(Transfer.from_did == did,Transfer.to_did == did)).order_by(Transfer.block_id.desc()) + + def serialize_item(self, item): + return { + 'type': 'transfer', + 'id': '{}-{}'.format(item.block_id, item.event_idx), + 'attributes': { + 'block_id': item.block_id, + 'event_idx': item.event_idx, + 'extrinsic_idx': item.extrinsic_idx, + 'from_account_id': item.from_account_id, + 'from_address': ss58_encode(item.from_account_id, SUBSTRATE_ADDRESS_TYPE), + 'to_account_id': item.to_account_id, + 'to_address': ss58_encode(item.to_account_id, SUBSTRATE_ADDRESS_TYPE), + 'balance': float(item.balance), + 'from_did': item.from_did, + 'to_did': item.to_did, + 'fee': float(item.fee), + 'datetime': item.datetime.isoformat() + } + } + +def decimal_default_proc(obj): + if isinstance(obj, decimal.Decimal): + return float(obj) + raise TypeError + +class DidListResource(JSONAPIListResource): + def get_query(self): + return Did.query(self.session) + +class DidDetailResource(JSONAPIDetailResource): + def get_item(self, item_id): + if item_id and item_id.startswith('0x'): + return Did.query(self.session).filter_by(did_hash=item_id[2:]).first() + else: + return Did.query(self.session).get(item_id) + +class DidDetailBySocialAccountResource(JSONAPIDetailResource): + def get_item(self, item_id): + return Did.query(self.session).filter_by(social_account_hash=item_id).first() + +class DidMembersResource(JSONAPIListResource2): + def get_item_url_name(self): + return 'did_hash' + def get_query(self,did_hash): + return Did.query(self.session).filter_by(superior = did_hash) + def has_total(self): + return True + +class DidInviteRanking(JSONAPIListResource): + def get_query(self): + m = self.session.execute("select t1.did,t1.social_account_hash,count(t1.did) num from data_did t1 left join data_did t2 on t2.`superior` = concat('0x',t1.`did_hash`) group by t1.did,t1.social_account_hash order by count(t1.did) desc limit 0,10") + result = [] + for item in m: + result.append([item.did,item.social_account_hash,item.num]) + return result + def serialize_item(self, item): + return item class AccountResource(JSONAPIListResource): @@ -314,23 +396,29 @@ def get_query(self): Account.updated_at_block.desc() ) - + def convert_to_did_items(self): + return ['address'] class AccountDetailResource(JSONAPIDetailResource): cache_expiration_time = 6 def __init__(self): RuntimeConfiguration().update_type_registry(load_type_registry_preset('default')) - if TYPE_REGISTRY != 'default': - RuntimeConfiguration().update_type_registry(load_type_registry_preset(TYPE_REGISTRY)) + if TYPE_REGISTRY_PATH: + RuntimeConfiguration().update_type_registry(load_type_registry_file(TYPE_REGISTRY_PATH)) super(AccountDetailResource, self).__init__() def get_item(self, item_id): if item_id[0:2] == '0x': return Account.query(self.session).filter_by(id=item_id[2:]).first() + elif item_id[0:3] == 'did': + did = Did.query(self.session).filter_by(did=item_id).first() + return Account.query(self.session).filter_by(address = did.address).first() else: return Account.query(self.session).filter_by(address=item_id).first() - + def convert_to_did_items(self): + return ['address'] + def get_relationships(self, include_list, item): relationships = {} @@ -345,53 +433,35 @@ def get_relationships(self, include_list, item): return relationships def serialize_item(self, item): - substrate = SubstrateInterface(SUBSTRATE_RPC_URL, metadata_version=SUBSTRATE_METADATA_VERSION) + substrate = SubstrateInterface(SUBSTRATE_RPC_URL) data = item.serialize() storage_call = RuntimeStorage.query(self.session).filter_by( - module_id='balances', - name='FreeBalance', + module_id='system', + name='Account', ).order_by(RuntimeStorage.spec_version.desc()).first() + + print(storage_call) - data['attributes']['free_balance'] = substrate.get_storage( + account = substrate.get_storage( block_hash=None, - module='Balances', - function='FreeBalance', + module='System', + function='Account', params=item.id, return_scale_type=storage_call.type_value, hasher=storage_call.type_hasher, metadata_version=SUBSTRATE_METADATA_VERSION ) - storage_call = RuntimeStorage.query(self.session).filter_by( - module_id='balances', - name='ReservedBalance', - ).order_by(RuntimeStorage.spec_version.desc()).first() + print('----------------') - data['attributes']['reserved_balance'] = substrate.get_storage( - block_hash=None, - module='Balances', - function='ReservedBalance', - params=item.id, - return_scale_type=storage_call.type_value, - hasher=storage_call.type_hasher, - metadata_version=SUBSTRATE_METADATA_VERSION - ) + print(account) - storage_call = RuntimeStorage.query(self.session).filter_by( - module_id='system', - name='AccountNonce', - ).order_by(RuntimeStorage.spec_version.desc()).first() + data['attributes']['free_balance'] = account['data']['free'] - data['attributes']['nonce'] = substrate.get_storage( - block_hash=None, - module='System', - function='AccountNonce', - params=item.id, - return_scale_type=storage_call.type_value, - hasher=storage_call.type_hasher, - metadata_version=SUBSTRATE_METADATA_VERSION - ) + data['attributes']['reserved_balance'] = account['data']['reserved'] + + data['attributes']['nonce'] = account['nonce'] return data @@ -403,6 +473,8 @@ def get_query(self): AccountIndex.updated_at_block.desc() ) + def convert_to_did_items(self): + return ['address'] class AccountIndexDetailResource(JSONAPIDetailResource): @@ -463,7 +535,8 @@ def apply_filters(self, query, params): if params.get('filter[latestSession]'): - session = Session.query(self.session).order_by(Session.id.desc()).first() + session = Session.query(self.session).order_by( + Session.id.desc()).first() query = query.filter_by(session_id=session.id) @@ -503,7 +576,8 @@ def apply_filters(self, query, params): if params.get('filter[latestSession]'): - session = Session.query(self.session).order_by(Session.id.desc()).first() + session = Session.query(self.session).order_by( + Session.id.desc()).first() query = query.filter_by(session_id=session.id) @@ -683,7 +757,8 @@ def apply_filters(self, query, params): if params.get('filter[latestRuntime]'): - latest_runtime = Runtime.query(self.session).order_by(Runtime.spec_version.desc()).first() + latest_runtime = Runtime.query(self.session).order_by( + Runtime.spec_version.desc()).first() query = query.filter_by(spec_version=latest_runtime.spec_version) @@ -734,7 +809,8 @@ def apply_filters(self, query, params): if params.get('filter[latestRuntime]'): - latest_runtime = Runtime.query(self.session).order_by(Runtime.spec_version.desc()).first() + latest_runtime = Runtime.query(self.session).order_by( + Runtime.spec_version.desc()).first() query = query.filter_by(spec_version=latest_runtime.spec_version) @@ -790,7 +866,8 @@ def apply_filters(self, query, params): if params.get('filter[latestRuntime]'): - latest_runtime = Runtime.query(self.session).order_by(Runtime.spec_version.desc()).first() + latest_runtime = Runtime.query(self.session).order_by( + Runtime.spec_version.desc()).first() query = query.filter_by(spec_version=latest_runtime.spec_version) @@ -810,7 +887,8 @@ def apply_filters(self, query, params): if params.get('filter[latestRuntime]'): - latest_runtime = Runtime.query(self.session).order_by(Runtime.spec_version.desc()).first() + latest_runtime = Runtime.query(self.session).order_by( + Runtime.spec_version.desc()).first() query = query.filter_by(spec_version=latest_runtime.spec_version) @@ -866,7 +944,8 @@ class RuntimeConstantListResource(JSONAPIListResource): def get_query(self): return RuntimeConstant.query(self.session).order_by( - RuntimeConstant.spec_version.desc(), RuntimeConstant.module_id.asc(), RuntimeConstant.name.asc() + RuntimeConstant.spec_version.desc( + ), RuntimeConstant.module_id.asc(), RuntimeConstant.name.asc() ) @@ -878,4 +957,4 @@ def get_item(self, item_id): spec_version=spec_version, module_id=module_id, name=name - ).first() + ).first() \ No newline at end of file diff --git a/app/settings.py b/app/settings.py index d8cdcdf..476b593 100644 --- a/app/settings.py +++ b/app/settings.py @@ -34,6 +34,8 @@ SUBSTRATE_METADATA_VERSION = int(os.environ.get("SUBSTRATE_METADATA_VERSION", 8)) TYPE_REGISTRY = os.environ.get("TYPE_REGISTRY", "default") +TYPE_REGISTRY_PATH = os.environ.get("TYPE_REGISTRY_PATH", None) + DOGPILE_CACHE_SETTINGS = { diff --git a/app/type_registry/prochain.json b/app/type_registry/prochain.json new file mode 100644 index 0000000..8d088a0 --- /dev/null +++ b/app/type_registry/prochain.json @@ -0,0 +1,128 @@ +{ + "types": { + "Keys": { + "type": "struct", + "type_mapping": [ + ["grandpa", "AccountId"], + ["babe", "AccountId"], + ["im_online", "AccountId"], + ["authority_discovery", "AccountId"] + ] + }, + "Did":"Vec", + "BlockNumber": "U32", + "ExternalAddress":{ + "type":"struct", + "type_mapping":[ + ["btc","Vec"], + ["eth","Vec"], + ["eos","Vec"] + ] + }, + "EventLogSource":{ + "type":"struct", + "type_mapping":[ + ["event_name","Vec"], + ["event_url","Vec"], + ["event_data","Vec"] + ] + }, + "LockedRecords":{ + "type":"struct", + "type_mapping":[ + ["locked_time", "Moment"], + ["locked_period", "Moment"], + ["locked_funds", "Balance"], + ["rewards_ratio", "u64"], + ["max_quota", "u64"] + ]}, + "UnlockedRecords": { + "type":"struct", + "type_mapping": + [ + ["unlock_time", "Moment"], + ["unlock_funds", "Balance"] + ] + }, + "MetadataRecord" : { + "type":"struct", + "type_mapping": + [ + ["address", "AccountId"], + ["superior", "Hash"], + ["creator", "AccountId"], + ["did", "Did"], + ["locked_records", "Option>"], + ["unlock_records", "Option>"], + ["is_partner","bool"], + ["social_account", "Option"], + ["subordinate_count", "u64"], + ["group_name","Option>"], + ["external_address", "ExternalAddress"] + ] + }, + "Value": "u32", + "BTCValue" :{ + "type":"struct", + "type_mapping":[ + ["block_number","BlockNumber"], + ["price","Value"] + ] + }, + "AdsMetadata":{ + "type":"struct", + "type_mapping":[ + ["advertiser", "Vec"], + ["topic", "Vec"], + ["total_amount", "Balance"], + ["surplus", "Balance"], + ["gas_fee_used", "Balance"], + ["single_click_fee", "Balance"], + ["create_time", "Moment"], + ["period", "Moment"] + ] + }, + "EventHTLC":{ + "type":"struct", + "type_mapping":[ + ["eth_contract_addr","Vec"], + ["htlc_block_number","BlockNumber"], + ["event_block_number","BlockNumber"], + ["expire_height","u32"], + ["random_number_hash","Vec"], + ["swap_id","Hash"], + ["sender_addr","Vec"], + ["sender_chain_type","HTLCChain"], + ["receiver_addr","Hash"], + ["receiver_chain_type","HTLCChain"], + ["recipient_addr","Vec"], + ["out_amount","Balance"], + ["event_type","HTLCType"] + ] + }, + "HTLCStates" :{ + "type":"enum", + "value_list":[ + "INVALID", + "OPEN", + "COMPLETED", + "EXPIRED" + ] + }, + "HTLCChain":{ + "type":"enum", + "value_list":[ + "ETHMain", + "PRA" + ] + }, + "HTLCType":{ + "type":"enum", + "value_list":[ + "HTLC", + "Claimed", + "Refunded" + ] + } + } +} diff --git a/requirements.txt b/requirements.txt index 7e915ae..93a9000 100644 --- a/requirements.txt +++ b/requirements.txt @@ -36,5 +36,5 @@ urllib3==1.25.3 xxhash==1.4.1 zipp==0.5.2 -git+https://github.com/polkascan/py-scale-codec.git@master#egg=scalecodec -git+https://github.com/polkascan/py-substrate-interface.git@master#egg=substrateinterface +scalecodec>=0.9.17 +substrate-interface>=0.9.3 diff --git a/start.sh b/start.sh index 7df0100..a4790e0 100755 --- a/start.sh +++ b/start.sh @@ -7,13 +7,11 @@ fi echo "===========================" echo "Environment: $ENVIRONMENT" echo "===========================" - +export PYTHONPATH=$PYTHONPATH:./py-substrate-interface/:./py-scale-codec/ echo "Running gunicorn..." if [ "$ENVIRONMENT" = "dev" ]; then # Expand path to local versions of packages - export PYTHONPATH=$PYTHONPATH:./py-substrate-interface/:./py-scale-codec/ - gunicorn -b 0.0.0.0:8000 --workers=1 app.main:app --reload --timeout 600 fi