from decimal import Decimal
import logging
import requests
import urllib
from django.db.models import Q, signals
from django.core.exceptions import ValidationError
from pytezos.operation.result import OperationResult
from pytezos.rpc import RpcError
from djwebdapp.exceptions import PermanentError
from djwebdapp.models import Account, account_setup
from djwebdapp.provider import Provider
from djwebdapp_tezos.models import TezosTransaction
from pytezos import pytezos, Key
[docs]class TezosProvider(Provider):
logger = logging.getLogger('djwebdapp_tezos')
transaction_class = TezosTransaction
[docs] def generate_secret_key(self):
key = Key.generate(export=False)
return key.public_key_hash(), key.secret_exponent
[docs] def get_client(self, **kwargs):
if self.wallet:
kwargs['key'] = Key.from_secret_exponent(
self.wallet.get_secret_key()
)
return pytezos.using(
shell=self.blockchain.node_set.first().endpoint,
**kwargs,
)
@property
def head(self):
return self.client.shell.head.metadata()['level_info']['level']
def get_address(self):
return self.client.key.public_key_hash()
[docs] def get_balance(self, address=None):
return Decimal(
self.client.account(address or self.get_address())['balance']
)
[docs] def index_level(self, level):
block = self.client.shell.blocks[level]
for ops in block.operations():
for number, op in enumerate(ops):
for content in op.get('contents', []):
self.index_content(level, number, op, content)
def index_content(self, level, number, op, content):
self.logger.info(f'Indexing content {number}@{level} {op["hash"]}')
# index content normally
hash = op['hash']
if (
content['kind'] == 'origination'
and 'metadata' in content
):
result = content['metadata']['operation_result']
address = result['originated_contracts'][0]
if (
address not in self.addresses
and hash not in self.hashes
):
# skip unknown contract originations
return
self.index_contract(level, op, content, number=number)
elif (
content['kind'] == 'transaction'
and (
content.get('destination', None) in self.addresses
or hash in self.hashes
)
):
self.index_call(level, op, content, number=number)
else:
# index internal transactions if necessary
for internal_op in OperationResult.iter_contents(content):
if (
internal_op.get('destination', '') in self.addresses
or internal_op.get('source', '') in self.addresses
):
self.index_call(
level,
op,
content,
number=number,
)
break
def index_contract(self, level, op, content, number):
self.logger.info(f'Syncing origination {op["hash"]}')
result = content['metadata']['operation_result']
address = result['originated_contracts'][0]
contract = self.transaction_class.objects.get(
Q(address=address) | Q(hash=op['hash'])
)
contract.level = level
contract.address = address
contract.hash = op['hash']
contract.gas = content['fee']
contract.metadata = content
contract.number = number
contract.sender, _ = self.blockchain.account_set.get_or_create(
address=op['contents'][0]['source'],
blockchain=self.blockchain,
)
contract.state_set('done')
def is_implicit_contract(self, address):
return len(address) == 36 and address[:2] == 'tz'
def index_origination(self, level, hash, content, caller=None,
number=None):
self.logger.info(f'Syncing origination {hash}')
originated_contracts = []
for originated_address in content['result']['originated_contracts']:
contract, created = self.transaction_class.objects.get_or_create(
address=originated_address,
blockchain=self.blockchain,
caller=caller,
)
contract.level = level
contract.hash = hash
contract.gas = content.get('fee', 0)
contract.metadata = content
contract.nonce = content.get('nonce', -1)
contract.sender = self.get_account(content['source'])
contract.number = number
contract.state_set('done')
originated_contracts.append(contract)
return originated_contracts
def get_account(self, address):
sender = Account.objects.filter(
address=address,
blockchain=self.blockchain,
).first()
if not sender:
sender = Account.objects.create(
address=address,
blockchain=self.blockchain,
index=False,
)
return sender
def index_transaction(self, level, hash, content, caller=None,
number=None):
self.logger.info(f'Syncing transaction {hash}')
counter = caller.counter if caller else content.get('counter', None)
# figure destination contract
destination_address = content['destination']
if self.is_implicit_contract(destination_address):
# this transaction targets an account
contract = None
receiver, _ = Account.objects.get_or_create(
blockchain=self.blockchain,
address=destination_address,
)
qs = receiver.transaction_received
else:
# this transaction targets a contract
receiver = None
contract = self.transaction_class.objects.filter(
blockchain=self.blockchain,
address=destination_address,
).first()
if not contract:
contract = self.transaction_class.objects.create(
blockchain=self.blockchain,
address=destination_address,
index=False,
number=number,
)
qs = contract.call_set.select_subclasses()
call = qs.filter(
hash=hash,
nonce=content.get('nonce', -1),
counter=counter,
# we shouldn't take the level into account
# when filtering due to confirm transactions
# that "overide" the level in djwebdapp.provider.index
# level=level, <-- don't uncomment this
).first()
if not call:
call = self.transaction_class(
hash=hash,
counter=counter,
contract=contract,
receiver=receiver,
blockchain=self.blockchain,
nonce=content.get('nonce', -1),
amount=int(content.get('amount', 0)),
state='held',
caller=caller,
number=number,
level=level,
)
# update call
call.metadata = content
call.sender = self.get_account(content['source'])
# patch against empty args in pytezes
if 'parameters' in content:
call.function = content['parameters']['entrypoint']
method = getattr(contract.interface, call.function)
args = method.decode(call.metadata['parameters']['value'])
if args == call.function:
call.args = []
else:
if call.function not in args:
call.args = args
else:
call.args = args[call.function]
# save and return call
call.state_set('done')
return call
def index_call(self, level, op, content, number=None):
self.logger.info(f'Syncing transaction {op["hash"]}')
operations = [op for op in OperationResult.iter_contents(content)]
internal_operations = operations[1:]
source = self.index_transaction(
level,
op['hash'],
content,
number=number,
)
internal_transactions = []
for operation_content in internal_operations:
if operation_content['kind'] == 'transaction':
if 'destination' in operation_content:
internal_transaction = self.index_transaction(
level,
op['hash'],
operation_content,
source,
number=number,
)
internal_transactions.append(internal_transaction)
if operation_content['kind'] == 'origination':
if 'originated_contracts' in operation_content['result']:
internal_originations = self.index_origination(
level,
op['hash'],
operation_content,
source,
number=number,
)
internal_transactions += internal_originations
return source
[docs] def transfer(self, transaction):
""" Execute a transfer transaction. """
"""
rpc error if balance too low :
RpcError ({'amount': '120000000000000000',
'balance': '3998464237867',
'contract': 'tz1gjaF81ZRRvdzjobyfVNsAeSC6PScjfQwN',
'id': 'proto.006-PsCARTHA.contract.balance_too_low',
'kind': 'temporary'},)
"""
tx = self.client.transaction(
destination=transaction.receiver.address,
amount=transaction.amount,
).autofill().sign()
result = self.write_transaction(tx, transaction)
return result
[docs] def deploy(self, transaction):
if not self.client.balance():
raise ValidationError(
f'{transaction.sender.address} needs more than 0 tezies')
elif not self.wallet.revealed:
try:
self.client.reveal().send(min_confirmations=1)
except RpcError as exc:
if len(exc.args) > 1:
raise
if not exc.args[0]['id'].endswith('previously_revealed_key'):
raise
self.wallet.revealed = True
self.wallet.save()
self.logger.debug(f'{transaction}.deploy(): start')
if transaction.kind == 'contract':
self.originate(transaction)
elif transaction.kind == 'function':
self.send(transaction)
elif transaction.kind == 'transfer':
self.transfer(transaction)
else:
transaction.error = f'Unknown transaction kind {transaction.kind}'
transaction.state_set('failed')
return
transaction.sender.last_level = self.head
transaction.sender.save()
self.logger.info(f'{transaction}.deploy(): success')
def originate(self, transaction):
tx = self.client.origination(dict(
code=transaction.micheline,
storage=transaction.get_args(),
)).autofill().sign()
self.write_transaction(tx, transaction)
def write_transaction(self, tx, transaction):
origination = tx.inject(
_async=False,
)
transaction.level = self.head + 1 # it'll be in the next block
transaction.gas = origination['contents'][0]['fee']
transaction.hash = origination['hash']
transaction.counter = origination['contents'][0]['counter']
transaction.save()
def send(self, transaction):
self.logger.debug(
f'{transaction}: counter = {self.client.account()["counter"]}'
)
ci = self.client.contract(transaction.contract.address)
method = getattr(ci, transaction.function)
try:
args = transaction.get_args()
if isinstance(args, dict):
tx = method(**args)
else:
tx = method(*args)
if transaction.amount:
tx = tx.with_amount(transaction.amount)
except ValueError as e:
raise PermanentError(*e.args)
self.write_transaction(tx, transaction)
[docs] def download(self, target):
"""
Import transactions from tzkt for a contract
"""
api = self.blockchain.configuration.get(
'tzkt_url',
'https://api.tzkt.io', # default indexer?
)
url = f'{api}/v1/operations/transactions?'
url += f'&target={urllib.parse.quote(target)}'
url += f'&level.le={self.head - 1}'
contract, _ = self.transaction_class.objects.get_or_create(
blockchain=self.blockchain,
address=target,
)
def get(offset, limit):
return requests.get(url + f'&offset={offset}&limit={limit}').json()
def yield_operations():
offset = 0
limit = 10_000
while data := get(offset, limit):
for operation in data:
if operation['type'] != 'transaction':
continue
if operation['status'] != 'applied':
continue
yield operation
offset += limit
total = 0
operations = dict()
for operation in yield_operations():
total += 1
# generate a key for the operation group
key = (
operation['level'],
operation['hash'],
operation['counter'],
operation.get('nonce', 0),
)
operations[key] = operation
# disable automatic refresh of balances to speed up the whole process
signals.pre_save.disconnect(account_setup, sender=Account)
# we're also going to cache Account objects
accounts = dict()
# fetch all calls we already have in DB
calls = {
(call.level, call.hash, call.counter, call.nonce): call
for call in self.transaction_class.objects.filter(
contract=contract,
state__in=('confirm', 'done'),
)
}
number = 0
for key in sorted(operations.keys()):
number += 1
level, hash, counter, nonce = key
self.logger.debug(' '.join((
f'[{number}/{total}]',
hash,
f'level={level}',
f'counter={counter}',
f'nonce={nonce if isinstance(nonce, int) else -1}',
)))
if key in calls:
continue # let's not update for now
operation = operations[key]
if operation['sender']['address'] in accounts:
sender = accounts[operation['sender']['address']]
else:
sender, _ = Account.objects.get_or_create(
address=operation['sender']['address'],
blockchain=self.blockchain,
)
accounts[sender.address] = sender
function = operation['parameter']['entrypoint']
args = operation['parameter']['value']
for key, value in args.items():
try:
args[key] = int(value)
except ValueError:
continue
call = self.transaction_class(
blockchain=self.blockchain,
contract=contract,
sender=sender,
level=level,
hash=hash,
counter=counter,
nonce=nonce if isinstance(nonce, int) else -1,
kind='call',
function=function,
args=args,
metadata=operation,
gas=operation['gasUsed'],
)
call.state_set('done')
# reconnect Account signal
signals.pre_save.connect(account_setup, sender=Account)