From e012d0c9c86558628a289a0375f1a55204691491 Mon Sep 17 00:00:00 2001 From: Jeannette Date: Tue, 9 Jul 2024 15:32:13 -0500 Subject: [PATCH] refactoring dss deployment --- chief_keeper/chief_keeper.py | 2 +- chief_keeper/makerdao_utils/__init__.py | 5 + chief_keeper/makerdao_utils/auctions.py | 929 ++++++++++++++++++ chief_keeper/makerdao_utils/dss_deployment.py | 302 ++++++ .../utils/{address_utils.py => address.py} | 0 chief_keeper/utils/big_number.py | 412 ++++++++ .../{blockchain_utils.py => blockchain.py} | 2 +- chief_keeper/utils/contract.py | 90 ++ chief_keeper/utils/register_keys.py | 3 +- chief_keeper/utils/transact.py | 431 ++++++++ chief_keeper/utils/utils.py | 96 ++ 11 files changed, 2268 insertions(+), 4 deletions(-) create mode 100644 chief_keeper/makerdao_utils/__init__.py create mode 100644 chief_keeper/makerdao_utils/auctions.py create mode 100644 chief_keeper/makerdao_utils/dss_deployment.py rename chief_keeper/utils/{address_utils.py => address.py} (100%) create mode 100644 chief_keeper/utils/big_number.py rename chief_keeper/utils/{blockchain_utils.py => blockchain.py} (99%) create mode 100644 chief_keeper/utils/contract.py create mode 100644 chief_keeper/utils/transact.py create mode 100644 chief_keeper/utils/utils.py diff --git a/chief_keeper/chief_keeper.py b/chief_keeper/chief_keeper.py index 3b9925c..b24c908 100644 --- a/chief_keeper/chief_keeper.py +++ b/chief_keeper/chief_keeper.py @@ -33,7 +33,7 @@ from .utils.keeper_lifecycle import Lifecycle from. utils.register_keys import register_keys -from .utils.blockchain_utils import initialize_blockchain_connection +from .utils.blockchain import initialize_blockchain_connection # from pymaker import Address, web3_via_http # from pymaker.util import is_contract_at diff --git a/chief_keeper/makerdao_utils/__init__.py b/chief_keeper/makerdao_utils/__init__.py new file mode 100644 index 0000000..8331b24 --- /dev/null +++ b/chief_keeper/makerdao_utils/__init__.py @@ -0,0 +1,5 @@ +# __init__.py in chief_keeper and chief_keeper/makerdao_utils directories + +""" +Initializer for the package. +""" \ No newline at end of file diff --git a/chief_keeper/makerdao_utils/auctions.py b/chief_keeper/makerdao_utils/auctions.py new file mode 100644 index 0000000..743bdde --- /dev/null +++ b/chief_keeper/makerdao_utils/auctions.py @@ -0,0 +1,929 @@ +# This file is part of Maker Keeper Framework. +# +# Copyright (C) 2018-2019 reverendus, bargst, EdNoepel +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from datetime import datetime +import logging +from pprint import pformat +from typing import List +from web3 import Web3 + +from web3._utils.events import get_event_data + +from eth_abi.codec import ABICodec +from eth_abi.registry import registry as default_registry + +from chief_keeper.utils.address import Address +from chief_keeper.utils.contract import Contract +from chief_keeper.utils.transact import Transact + + +from pymaker.dss import Dog, Vat +from pymaker.logging import LogNote +from pymaker.numeric import Wad, Rad, Ray +from pymaker.token import ERC20Token + + +def toBytes(string: str): + assert(isinstance(string, str)) + return string.encode('utf-8').ljust(32, bytes(1)) + + +logger = logging.getLogger() + + +class AuctionContract(Contract): + """Abstract baseclass shared across all auction contracts.""" + def __init__(self, web3: Web3, address: Address, abi: list): + if self.__class__ == AuctionContract: + raise NotImplemented('Abstract class; please call Clipper, Flapper, Flipper, or Flopper ctor') + assert isinstance(web3, Web3) + assert isinstance(address, Address) + assert isinstance(abi, list) + + self.web3 = web3 + self.address = address + self.abi = abi + self._contract = self._get_contract(web3, abi, address) + + self.log_note_abi = None + self.kick_abi = None + for member in abi: + if not self.log_note_abi and member.get('name') == 'LogNote': + self.log_note_abi = member + elif not self.kick_abi and member.get('name') == 'Kick': + self.kick_abi = member + + def approve(self, source: Address, approval_function): + """Approve the auction to access our collateral, Dai, or MKR so we can participate in auctions. + + For available approval functions (i.e. approval modes) see `directly` and `hope_directly` + in `pymaker.approval`. + + Args: + source: Address of the contract or token relevant to the auction (for Flipper and Flopper pass Vat address, + for Flapper pass MKR token address) + approval_function: Approval function (i.e. approval mode) + """ + assert isinstance(source, Address) + assert(callable(approval_function)) + + approval_function(token=ERC20Token(web3=self.web3, address=source), + spender_address=self.address, spender_name=self.__class__.__name__) + + def wards(self, address: Address) -> bool: + assert isinstance(address, Address) + + return bool(self._contract.functions.wards(address.address).call()) + + def vat(self) -> Address: + """Returns the `vat` address. + Returns: + The address of the `vat` contract. + """ + return Address(self._contract.functions.vat().call()) + + def get_past_lognotes(self, abi: list, from_block: int, to_block: int = None, chunk_size=20000) -> List[LogNote]: + current_block = self._contract.web3.eth.blockNumber + assert isinstance(from_block, int) + assert from_block < current_block + if to_block is None: + to_block = current_block + else: + assert isinstance(to_block, int) + assert to_block >= from_block + assert to_block <= current_block + assert chunk_size > 0 + assert isinstance(abi, list) + + logger.debug(f"Consumer requested auction data from block {from_block} to {to_block}") + start = from_block + end = None + chunks_queried = 0 + events = [] + while end is None or start <= to_block: + chunks_queried += 1 + end = min(to_block, start + chunk_size) + + filter_params = { + 'address': self.address.address, + 'fromBlock': start, + 'toBlock': end + } + logger.debug(f"Querying logs from block {start} to {end} ({end-start} blocks); " + f"accumulated {len(events)} events in {chunks_queried-1} requests") + + logs = self.web3.eth.getLogs(filter_params) + events.extend(list(map(lambda l: self.parse_event(l), logs))) + start += chunk_size + + return list(filter(lambda l: l is not None, events)) + + def parse_event(self, event): + raise NotImplemented() + + +class DealableAuctionContract(AuctionContract): + """Abstract baseclass shared across original auction contracts.""" + + class DealLog: + def __init__(self, lognote: LogNote): + # This is whoever called `deal`, which could differ from the `guy` who won the auction + self.usr = Address(lognote.usr) + self.id = Web3.toInt(lognote.arg1) + self.block = lognote.block + self.tx_hash = lognote.tx_hash + + def __repr__(self): + return f"AuctionContract.DealLog({pformat(vars(self))})" + + def __init__(self, web3: Web3, address: Address, abi: list, bids: callable): + if self.__class__ == DealableAuctionContract: + raise NotImplemented('Abstract class; please call Flipper, Flapper, or Flopper ctor') + super(DealableAuctionContract, self).__init__(web3, address, abi) + + self._bids = bids + + def active_auctions(self) -> list: + active_auctions = [] + auction_count = self.kicks()+1 + for index in range(1, auction_count): + bid = self._bids(index) + if bid.guy != Address("0x0000000000000000000000000000000000000000"): + now = datetime.now().timestamp() + if (bid.tic == 0 or now < bid.tic) and now < bid.end: + active_auctions.append(bid) + index += 1 + return active_auctions + + def beg(self) -> Wad: + """Returns the percentage minimum bid increase. + + Returns: + The percentage minimum bid increase. + """ + return Wad(self._contract.functions.beg().call()) + + def ttl(self) -> int: + """Returns the bid lifetime. + + Returns: + The bid lifetime (in seconds). + """ + return int(self._contract.functions.ttl().call()) + + def tau(self) -> int: + """Returns the total auction length. + + Returns: + The total auction length (in seconds). + """ + return int(self._contract.functions.tau().call()) + + def kicks(self) -> int: + """Returns the number of auctions started so far. + + Returns: + The number of auctions started so far. + """ + return int(self._contract.functions.kicks().call()) + + def deal(self, id: int) -> Transact: + assert(isinstance(id, int)) + + return Transact(self, self.web3, self.abi, self.address, self._contract, 'deal', [id]) + + def tick(self, id: int) -> Transact: + """Resurrect an auction which expired without any bids.""" + assert(isinstance(id, int)) + + return Transact(self, self.web3, self.abi, self.address, self._contract, 'tick', [id]) + + +class Flipper(DealableAuctionContract): + """A client for the `Flipper` contract, used to interact with collateral auctions. + + You can find the source code of the `Flipper` contract here: + . + + Attributes: + web3: An instance of `Web` from `web3.py`. + address: Ethereum address of the `Flipper` contract. + + Event signatures: + 0x65fae35e: (deployment-related) + 0x9c52a7f1: (deployment-related) + 0x29ae8114: file + 0xc84ce3a1172f0dec3173f04caaa6005151a4bfe40d4c9f3ea28dba5f719b2a7a: kick + 0x4b43ed12: tend + 0x5ff3a382: dent + 0xc959c42b: deal + """ + + abi = Contract._load_abi(__name__, 'abi/Flipper.abi') + bin = Contract._load_bin(__name__, 'abi/Flipper.bin') + + class Bid: + def __init__(self, id: int, bid: Rad, lot: Wad, guy: Address, tic: int, end: int, + usr: Address, gal: Address, tab: Rad): + assert(isinstance(id, int)) + assert(isinstance(bid, Rad)) + assert(isinstance(lot, Wad)) + assert(isinstance(guy, Address)) + assert(isinstance(tic, int)) + assert(isinstance(end, int)) + assert(isinstance(usr, Address)) + assert(isinstance(gal, Address)) + assert(isinstance(tab, Rad)) + + self.id = id + self.bid = bid + self.lot = lot + self.guy = guy + self.tic = tic + self.end = end + self.usr = usr + self.gal = gal + self.tab = tab + + def __repr__(self): + return f"Flipper.Bid({pformat(vars(self))})" + + class KickLog: + def __init__(self, log): + args = log['args'] + self.id = args['id'] + self.lot = Wad(args['lot']) + self.bid = Rad(args['bid']) + self.tab = Rad(args['tab']) + self.usr = Address(args['usr']) + self.gal = Address(args['gal']) + self.block = log['blockNumber'] + self.tx_hash = log['transactionHash'].hex() + + def __repr__(self): + return f"Flipper.KickLog({pformat(vars(self))})" + + class TendLog: + def __init__(self, lognote: LogNote): + self.guy = Address(lognote.usr) + self.id = Web3.toInt(lognote.arg1) + self.lot = Wad(Web3.toInt(lognote.arg2)) + self.bid = Rad(Web3.toInt(lognote.get_bytes_at_index(2))) + self.block = lognote.block + self.tx_hash = lognote.tx_hash + + def __repr__(self): + return f"Flipper.TendLog({pformat(vars(self))})" + + class DentLog: + def __init__(self, lognote: LogNote): + self.guy = Address(lognote.usr) + self.id = Web3.toInt(lognote.arg1) + self.lot = Wad(Web3.toInt(lognote.arg2)) + self.bid = Rad(Web3.toInt(lognote.get_bytes_at_index(2))) + self.block = lognote.block + self.tx_hash = lognote.tx_hash + + def __repr__(self): + return f"Flipper.DentLog({pformat(vars(self))})" + + def __init__(self, web3: Web3, address: Address): + super(Flipper, self).__init__(web3, address, Flipper.abi, self.bids) + + def bids(self, id: int) -> Bid: + """Returns the auction details. + + Args: + id: Auction identifier. + + Returns: + The auction details. + """ + assert(isinstance(id, int)) + + array = self._contract.functions.bids(id).call() + + return Flipper.Bid(id=id, + bid=Rad(array[0]), + lot=Wad(array[1]), + guy=Address(array[2]), + tic=int(array[3]), + end=int(array[4]), + usr=Address(array[5]), + gal=Address(array[6]), + tab=Rad(array[7])) + + def tend(self, id: int, lot: Wad, bid: Rad) -> Transact: + assert(isinstance(id, int)) + assert(isinstance(lot, Wad)) + assert(isinstance(bid, Rad)) + + return Transact(self, self.web3, self.abi, self.address, self._contract, 'tend', [id, lot.value, bid.value]) + + def dent(self, id: int, lot: Wad, bid: Rad) -> Transact: + assert(isinstance(id, int)) + assert(isinstance(lot, Wad)) + assert(isinstance(bid, Rad)) + + return Transact(self, self.web3, self.abi, self.address, self._contract, 'dent', [id, lot.value, bid.value]) + + def past_logs(self, from_block: int, to_block: int = None, chunk_size=20000): + logs = super().get_past_lognotes(Flipper.abi, from_block, to_block, chunk_size) + + history = [] + for log in logs: + if log is None: + continue + elif isinstance(log, Flipper.KickLog): + history.append(log) + elif log.sig == '0x4b43ed12': + history.append(Flipper.TendLog(log)) + elif log.sig == '0x5ff3a382': + history.append(Flipper.DentLog(log)) + elif log.sig == '0xc959c42b': + history.append(DealableAuctionContract.DealLog(log)) + return history + + def parse_event(self, event): + signature = Web3.toHex(event['topics'][0]) + codec = ABICodec(default_registry) + if signature == "0xc84ce3a1172f0dec3173f04caaa6005151a4bfe40d4c9f3ea28dba5f719b2a7a": + event_data = get_event_data(codec, self.kick_abi, event) + return Flipper.KickLog(event_data) + else: + event_data = get_event_data(codec, self.log_note_abi, event) + return LogNote(event_data) + + def __repr__(self): + return f"Flipper('{self.address}')" + + +class Flapper(DealableAuctionContract): + """A client for the `Flapper` contract, used to interact with surplus auctions. + + You can find the source code of the `Flapper` contract here: + . + + Attributes: + web3: An instance of `Web` from `web3.py`. + address: Ethereum address of the `Flapper` contract. + + Event signatures: + 0x65fae35e: (deployment-related) + 0x9c52a7f1: (deployment-related) + 0xe6dde59cbc017becba89714a037778d234a84ce7f0a137487142a007e580d609: kick + 0x29ae8114: file + 0x4b43ed12: tend + 0xc959c42b: deal + """ + + abi = Contract._load_abi(__name__, 'abi/Flapper.abi') + bin = Contract._load_bin(__name__, 'abi/Flapper.bin') + + class Bid: + def __init__(self, id: int, bid: Wad, lot: Rad, guy: Address, tic: int, end: int): + assert(isinstance(id, int)) + assert(isinstance(bid, Wad)) # MKR + assert(isinstance(lot, Rad)) # DAI + assert(isinstance(guy, Address)) + assert(isinstance(tic, int)) + assert(isinstance(end, int)) + + self.id = id + self.bid = bid + self.lot = lot + self.guy = guy + self.tic = tic + self.end = end + + def __repr__(self): + return f"Flapper.Bid({pformat(vars(self))})" + + class KickLog: + def __init__(self, log): + args = log['args'] + self.id = args['id'] + self.lot = Rad(args['lot']) + self.bid = Wad(args['bid']) + self.block = log['blockNumber'] + self.tx_hash = log['transactionHash'].hex() + + def __repr__(self): + return f"Flapper.KickLog({pformat(vars(self))})" + + class TendLog: + def __init__(self, lognote: LogNote): + self.guy = Address(lognote.usr) + self.id = Web3.toInt(lognote.arg1) + self.lot = Rad(Web3.toInt(lognote.arg2)) + self.bid = Wad(Web3.toInt(lognote.get_bytes_at_index(2))) + self.block = lognote.block + self.tx_hash = lognote.tx_hash + + def __repr__(self): + return f"Flapper.TendLog({pformat(vars(self))})" + + def __init__(self, web3: Web3, address: Address): + super(Flapper, self).__init__(web3, address, Flapper.abi, self.bids) + + def live(self) -> bool: + return self._contract.functions.live().call() > 0 + + def bids(self, id: int) -> Bid: + """Returns the auction details. + + Args: + id: Auction identifier. + + Returns: + The auction details. + """ + assert(isinstance(id, int)) + + array = self._contract.functions.bids(id).call() + + return Flapper.Bid(id=id, + bid=Wad(array[0]), + lot=Rad(array[1]), + guy=Address(array[2]), + tic=int(array[3]), + end=int(array[4])) + + def tend(self, id: int, lot: Rad, bid: Wad) -> Transact: + assert(isinstance(id, int)) + assert(isinstance(lot, Rad)) + assert(isinstance(bid, Wad)) + + return Transact(self, self.web3, self.abi, self.address, self._contract, 'tend', [id, lot.value, bid.value]) + + def yank(self, id: int) -> Transact: + """While `cage`d, refund current bid to the bidder""" + assert (isinstance(id, int)) + + return Transact(self, self.web3, self.abi, self.address, self._contract, 'yank', [id]) + + def past_logs(self, from_block: int, to_block: int = None, chunk_size=20000): + logs = super().get_past_lognotes(Flapper.abi, from_block, to_block, chunk_size) + + history = [] + for log in logs: + if log is None: + continue + elif isinstance(log, Flapper.KickLog): + history.append(log) + elif log.sig == '0x4b43ed12': + history.append(Flapper.TendLog(log)) + elif log.sig == '0xc959c42b': + history.append(DealableAuctionContract.DealLog(log)) + return history + + def parse_event(self, event): + signature = Web3.toHex(event['topics'][0]) + codec = ABICodec(default_registry) + if signature == "0xe6dde59cbc017becba89714a037778d234a84ce7f0a137487142a007e580d609": + event_data = get_event_data(codec, self.kick_abi, event) + return Flapper.KickLog(event_data) + else: + event_data = get_event_data(codec, self.log_note_abi, event) + return LogNote(event_data) + + def __repr__(self): + return f"Flapper('{self.address}')" + + +class Flopper(DealableAuctionContract): + """A client for the `Flopper` contract, used to interact with debt auctions. + + You can find the source code of the `Flopper` contract here: + . + + Attributes: + web3: An instance of `Web` from `web3.py`. + address: Ethereum address of the `Flopper` contract. + + Event signatures: + 0x65fae35e: (deployment-related) + 0x9c52a7f1: (deployment-related) + 0x29ae8114: file + 0x7e8881001566f9f89aedb9c5dc3d856a2b81e5235a8196413ed484be91cc0df6: kick + 0x5ff3a382: dent + 0xc959c42b: deal + """ + + abi = Contract._load_abi(__name__, 'abi/Flopper.abi') + bin = Contract._load_bin(__name__, 'abi/Flopper.bin') + + class Bid: + def __init__(self, id: int, bid: Rad, lot: Wad, guy: Address, tic: int, end: int): + assert(isinstance(id, int)) + assert(isinstance(bid, Rad)) + assert(isinstance(lot, Wad)) + assert(isinstance(guy, Address)) + assert(isinstance(tic, int)) + assert(isinstance(end, int)) + + self.id = id + self.bid = bid + self.lot = lot + self.guy = guy + self.tic = tic + self.end = end + + def __repr__(self): + return f"Flopper.Bid({pformat(vars(self))})" + + class KickLog: + def __init__(self, log): + args = log['args'] + self.id = args['id'] + self.lot = Wad(args['lot']) + self.bid = Rad(args['bid']) + self.gal = Address(args['gal']) + self.block = log['blockNumber'] + self.tx_hash = log['transactionHash'].hex() + + def __repr__(self): + return f"Flopper.KickLog({pformat(vars(self))})" + + class DentLog: + def __init__(self, lognote: LogNote): + self.guy = Address(lognote.usr) + self.id = Web3.toInt(lognote.arg1) + self.lot = Wad(Web3.toInt(lognote.arg2)) + self.bid = Rad(Web3.toInt(lognote.get_bytes_at_index(2))) + self.block = lognote.block + self.tx_hash = lognote.tx_hash + + def __repr__(self): + return f"Flopper.DentLog({pformat(vars(self))})" + + def __init__(self, web3: Web3, address: Address): + assert isinstance(web3, Web3) + assert isinstance(address, Address) + + super(Flopper, self).__init__(web3, address, Flopper.abi, self.bids) + + def live(self) -> bool: + return self._contract.functions.live().call() > 0 + + def pad(self) -> Wad: + """Returns the lot increase applied after an auction has been `tick`ed.""" + + return Wad(self._contract.functions.pad().call()) + + def bids(self, id: int) -> Bid: + """Returns the auction details. + + Args: + id: Auction identifier. + + Returns: + The auction details. + """ + assert(isinstance(id, int)) + + array = self._contract.functions.bids(id).call() + + return Flopper.Bid(id=id, + bid=Rad(array[0]), + lot=Wad(array[1]), + guy=Address(array[2]), + tic=int(array[3]), + end=int(array[4])) + + def dent(self, id: int, lot: Wad, bid: Rad) -> Transact: + assert(isinstance(id, int)) + assert(isinstance(lot, Wad)) + assert(isinstance(bid, Rad)) + + return Transact(self, self.web3, self.abi, self.address, self._contract, 'dent', [id, lot.value, bid.value]) + + def yank(self, id: int) -> Transact: + """While `cage`d, refund current bid to the bidder""" + assert (isinstance(id, int)) + + return Transact(self, self.web3, self.abi, self.address, self._contract, 'yank', [id]) + + def past_logs(self, from_block: int, to_block: int = None, chunk_size=20000): + logs = super().get_past_lognotes(Flopper.abi, from_block, to_block, chunk_size) + + history = [] + for log in logs: + if log is None: + continue + elif isinstance(log, Flopper.KickLog): + history.append(log) + elif log.sig == '0x5ff3a382': + history.append(Flopper.DentLog(log)) + elif log.sig == '0xc959c42b': + history.append(DealableAuctionContract.DealLog(log)) + return history + + def parse_event(self, event): + signature = Web3.toHex(event['topics'][0]) + codec = ABICodec(default_registry) + if signature == "0x7e8881001566f9f89aedb9c5dc3d856a2b81e5235a8196413ed484be91cc0df6": + event_data = get_event_data(codec, self.kick_abi, event) + return Flopper.KickLog(event_data) + else: + event_data = get_event_data(codec, self.log_note_abi, event) + return LogNote(event_data) + + def __repr__(self): + return f"Flopper('{self.address}')" + + +class Clipper(AuctionContract): + """A client for the `Clipper` contract, used to interact with collateral auctions. + + You can find the source code of the `Clipper` contract here: + . + + Attributes: + web3: An instance of `Web` from `web3.py`. + address: Ethereum address of the `Clipper` contract. + """ + + abi = Contract._load_abi(__name__, 'abi/Clipper.abi') + bin = Contract._load_bin(__name__, 'abi/Clipper.bin') + + class KickLog: + def __init__(self, log): + args = log['args'] + self.id = args['id'] + self.top = Ray(args['top']) # starting price + self.tab = Rad(args['tab']) # debt + self.lot = Wad(args['lot']) # collateral + self.usr = Address(args['usr']) # liquidated vault + self.kpr = Address(args['kpr']) # keeper who barked + self.coin = Rad(args['coin']) # total kick incentive (tip + tab*chip) + self.block = log['blockNumber'] + self.tx_hash = log['transactionHash'].hex() + + def __repr__(self): + return f"Clipper.KickLog({pformat(vars(self))})" + + class TakeLog: + def __init__(self, log, sender): + args = log['args'] + self.id = args['id'] + self.max = Ray(args['max']) # Max bid price specified + self.price = Ray(args['price']) # Calculated bid price + self.owe = Rad(args['owe']) # Dai needed to satisfy the calculated bid price + self.tab = Rad(args['tab']) # Remaining debt + self.lot = Wad(args['lot']) # Remaining lot + self.usr = Address(args['usr']) # Liquidated vault + self.block = log['blockNumber'] + self.tx_hash = log['transactionHash'].hex() + self.sender = sender + + def __repr__(self): + return f"Clipper.TakeLog({pformat(vars(self))})" + + class RedoLog(KickLog): + # Same fields as KickLog + def __repr__(self): + return f"Clipper.RedoLog({pformat(vars(self))})" + + class Sale: + def __init__(self, id: int, pos: int, tab: Rad, lot: Wad, usr: Address, tic: int, top: Ray): + assert(isinstance(id, int)) + assert(isinstance(pos, int)) + assert(isinstance(tab, Rad)) + assert(isinstance(lot, Wad)) + assert(isinstance(usr, Address)) + assert(isinstance(tic, int)) + assert(isinstance(top, Ray)) + + self.id = id # auction identifier + self.pos = pos # active index + self.tab = tab # dai to raise + self.lot = lot # collateral to sell + self.usr = usr # liquidated urn address + self.tic = tic # auction start time + self.top = top # starting price + + def __repr__(self): + return f"Clipper.Sale({pformat(vars(self))})" + + def __init__(self, web3: Web3, address: Address): + super(Clipper, self).__init__(web3, address, Clipper.abi) + assert isinstance(web3, Web3) + assert isinstance(address, Address) + + self.web3 = web3 + self.address = address + self._contract = self._get_contract(web3, self.abi, address) + # Albeit more elegant, this is inconsistent with AuctionContract.vat(), a method call + self.calc = Address(self._contract.functions.calc().call()) + self.dog = Dog(web3, Address(self._contract.functions.dog().call())) + self.vat = Vat(web3, Address(self._contract.functions.vat().call())) + + self.take_abi = None + self.redo_abi = None + for member in self.abi: + if not self.take_abi and member.get('name') == 'Take': + self.take_abi = member + if not self.redo_abi and member.get('name') == 'Redo': + self.redo_abi = member + + def active_auctions(self) -> list: + active_auctions = [] + for index in range(1, self.kicks()+1): + sale = self.sales(index) + if sale.usr != Address.zero(): + active_auctions.append(sale) + index += 1 + return active_auctions + + def ilk_name(self) -> str: + ilk = self._contract.functions.ilk().call() + return Web3.toText(ilk.strip(bytes(1))) + + def buf(self) -> Ray: + """Multiplicative factor to increase starting price""" + return Ray(self._contract.functions.buf().call()) + + def tail(self) -> int: + """Time elapsed before auction reset""" + return int(self._contract.functions.tail().call()) + + def cusp(self) -> Ray: + """Percentage drop before auction reset""" + return Ray(self._contract.functions.cusp().call()) + + def chip(self) -> Wad: + """Percentage of tab to suck from vow to incentivize keepers""" + return Wad(self._contract.functions.chip().call()) + + def tip(self) -> Rad: + """Flat fee to suck from vow to incentivize keepers""" + return Rad(self._contract.functions.tip().call()) + + def chost(self) -> Rad: + """Ilk dust times the ilk chop""" + return Rad(self._contract.functions.chost().call()) + + def kicks(self) -> int: + """Number of auctions started so far.""" + return int(self._contract.functions.kicks().call()) + + def active_count(self) -> int: + """Number of active and redoable auctions.""" + return int(self._contract.functions.count().call()) + + def status(self, id: int) -> (bool, Ray, Wad, Rad): + """Indicates current state of the auction + Args: + id: Auction identifier. + """ + assert isinstance(id, int) + (needs_redo, price, lot, tab) = self._contract.functions.getStatus(id).call() + logging.debug(f"Auction {id} {'needs redo ' if needs_redo else ''}with price={float(Ray(price))} " + f"lot={float(Wad(lot))} tab={float(Rad(tab))}") + return needs_redo, Ray(price), Wad(lot), Rad(tab) + + def sales(self, id: int) -> Sale: + """Returns the auction details. + Args: + id: Auction identifier. + Returns: + The auction details. + """ + assert(isinstance(id, int)) + + array = self._contract.functions.sales(id).call() + + return Clipper.Sale(id=id, + pos=int(array[0]), + tab=Rad(array[1]), + lot=Wad(array[2]), + usr=Address(array[3]), + tic=int(array[4]), + top=Ray(array[5])) + + def validate_take(self, id: int, amt: Wad, max: Ray, our_address: Address = None): + """Raise assertion if collateral cannot be purchased from an auction as desired""" + assert isinstance(id, int) + assert isinstance(amt, Wad) + assert isinstance(max, Ray) + + if our_address: + assert isinstance(our_address, Address) + else: + our_address = Address(self.web3.eth.defaultAccount) + + (done, price, lot, tab) = self.status(id) + assert not done + assert max >= price + + slice: Wad = min(lot, amt) # Purchase as much as possible, up to amt + owe: Rad = Rad(slice) * Rad(price) # DAI needed to buy a slice of this sale + chost = self.chost() + + if Rad(owe) > tab: + owe = Rad(tab) + slice = Wad(owe / Rad(price)) + elif owe < tab and slice < lot: + if (tab - owe) < chost: + assert tab > chost + owe = tab - chost + slice = Wad(owe / Rad(price)) + + tab: Rad = tab - owe + lot: Wad = lot - slice + assert self.vat.dai(our_address) >= owe + logger.debug(f"Validated clip.take which will leave tab={float(tab)} and lot={float(lot)}") + + def take(self, id: int, amt: Wad, max: Ray, who: Address = None, data=b'') -> Transact: + """Buy amount of collateral from auction indexed by id. + Args: + id: Auction id + amt: Upper limit on amount of collateral to buy + max: Maximum acceptable price (DAI / collateral) + who: Receiver of collateral and external call address + data: Data to pass in external call; if length 0, no call is done + """ + assert isinstance(id, int) + assert isinstance(amt, Wad) + assert isinstance(max, Ray) + + if who: + assert isinstance(who, Address) + else: + who = Address(self.web3.eth.defaultAccount) + + return Transact(self, self.web3, self.abi, self.address, self._contract, 'take', + [id, amt.value, max.value, who.address, data]) + + def redo(self, id: int, kpr: Address = None) -> Transact: + """Restart an auction which ended without liquidating all collateral. + id: Auction id + kpr: Keeper that called dog.bark() + """ + assert isinstance(id, int) + assert isinstance(kpr, Address) or kpr is None + + if kpr: + assert isinstance(kpr, Address) + else: + kpr = Address(self.web3.eth.defaultAccount) + + return Transact(self, self.web3, self.abi, self.address, self._contract, 'redo', [id, kpr.address]) + + def upchost(self): + """Update the the cached dust*chop value following a governance change""" + return Transact(self, self.web3, self.abi, self.address, self._contract, 'upchost', []) + + def past_logs(self, from_block: int, to_block: int = None, chunk_size=20000): + logs = super().get_past_lognotes(Clipper.abi, from_block, to_block, chunk_size) + + history = [] + for log in logs: + if log is None: + continue + elif isinstance(log, Clipper.KickLog) \ + or isinstance(log, Clipper.TakeLog) \ + or isinstance(log, Clipper.RedoLog): + history.append(log) + else: + logger.debug(f"Found log with signature {log.sig}") + return history + + def parse_event(self, event): + signature = Web3.toHex(event['topics'][0]) + codec = ABICodec(default_registry) + if signature == "0x7c5bfdc0a5e8192f6cd4972f382cec69116862fb62e6abff8003874c58e064b8": + event_data = get_event_data(codec, self.kick_abi, event) + return Clipper.KickLog(event_data) + elif signature == "0x05e309fd6ce72f2ab888a20056bb4210df08daed86f21f95053deb19964d86b1": + event_data = get_event_data(codec, self.take_abi, event) + self._get_sender_for_eventlog(event_data) + return Clipper.TakeLog(event_data, self._get_sender_for_eventlog(event_data)) + elif signature == "0x275de7ecdd375b5e8049319f8b350686131c219dd4dc450a08e9cf83b03c865f": + event_data = get_event_data(codec, self.redo_abi, event) + return Clipper.RedoLog(event_data) + else: + logger.debug(f"Found event signature {signature}") + + def _get_sender_for_eventlog(self, event_data) -> Address: + tx_hash = event_data['transactionHash'].hex() + receipt = self.web3.eth.getTransactionReceipt(tx_hash) + return Address(receipt['from']) + + def __repr__(self): + return f"Clipper('{self.address}')" diff --git a/chief_keeper/makerdao_utils/dss_deployment.py b/chief_keeper/makerdao_utils/dss_deployment.py new file mode 100644 index 0000000..5d93256 --- /dev/null +++ b/chief_keeper/makerdao_utils/dss_deployment.py @@ -0,0 +1,302 @@ +# This file is part of Maker Keeper Framework. +# +# Copyright (C) 2017-2018 reverendus, bargst +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import json +import os +import re +from typing import Dict, List, Optional + + +from web3 import Web3 + +from chief_keeper.utils.address import Address + +from chief_keeper.makerdao_utils.auctions import Clipper, Flapper, Flipper, Flopper + +from pymaker.collateral import Collateral +from pymaker.dss import Cat, Dog, Jug, Pot, Spotter, TokenFaucet, Vat, Vow +from pymaker.join import DaiJoin, GemJoin, GemJoin5 +from pymaker.proxy import ProxyRegistry, DssProxyActionsDsr +from pymaker.feed import DSValue +from pymaker.governance import DSPause, DSChief +from pymaker.oracles import OSM +from pymaker.shutdown import ShutdownModule, End +from pymaker.token import DSToken, DSEthToken +from pymaker.cdpmanager import CdpManager +from pymaker.dsrmanager import DsrManager + +class DssDeployment: + """Represents a Dai Stablecoin System deployment for multi-collateral Dai (MCD). + + Static method `from_json()` should be used to instantiate all the objet of + a deployment from a json description of all the system addresses. + """ + + NETWORKS = { + "1": "mainnet" + } + + class Config: + def __init__(self, pause: DSPause, vat: Vat, vow: Vow, jug: Jug, cat: Cat, dog: Dog, flapper: Flapper, + flopper: Flopper, pot: Pot, dai: DSToken, dai_join: DaiJoin, mkr: DSToken, + spotter: Spotter, ds_chief: DSChief, esm: ShutdownModule, end: End, + proxy_registry: ProxyRegistry, dss_proxy_actions: DssProxyActionsDsr, cdp_manager: CdpManager, + dsr_manager: DsrManager, faucet: TokenFaucet, collaterals: Optional[Dict[str, Collateral]] = None): + self.pause = pause + self.vat = vat + self.vow = vow + self.jug = jug + self.cat = cat + self.dog = dog + self.flapper = flapper + self.flopper = flopper + self.pot = pot + self.dai = dai + self.dai_join = dai_join + self.mkr = mkr + self.spotter = spotter + self.ds_chief = ds_chief + self.esm = esm + self.end = end + self.proxy_registry = proxy_registry + self.dss_proxy_actions = dss_proxy_actions + self.cdp_manager = cdp_manager + self.dsr_manager = dsr_manager + self.faucet = faucet + self.collaterals = collaterals or {} + + @staticmethod + def from_json(web3: Web3, conf: str): + def address_in_configs(key: str, conf: str) -> bool: + if key not in conf: + return False + elif not conf[key]: + return False + elif conf[key] == "0x0000000000000000000000000000000000000000": + return False + else: + return True + + conf = json.loads(conf) + pause = DSPause(web3, Address(conf['MCD_PAUSE'])) + vat = Vat(web3, Address(conf['MCD_VAT'])) + vow = Vow(web3, Address(conf['MCD_VOW'])) + jug = Jug(web3, Address(conf['MCD_JUG'])) + cat = Cat(web3, Address(conf['MCD_CAT'])) if address_in_configs('MCD_CAT', conf) else None + dog = Dog(web3, Address(conf['MCD_DOG'])) if address_in_configs('MCD_DOG', conf) else None + dai = DSToken(web3, Address(conf['MCD_DAI'])) + dai_adapter = DaiJoin(web3, Address(conf['MCD_JOIN_DAI'])) + flapper = Flapper(web3, Address(conf['MCD_FLAP'])) + flopper = Flopper(web3, Address(conf['MCD_FLOP'])) + pot = Pot(web3, Address(conf['MCD_POT'])) + mkr = DSToken(web3, Address(conf['MCD_GOV'])) + spotter = Spotter(web3, Address(conf['MCD_SPOT'])) + ds_chief = DSChief(web3, Address(conf['MCD_ADM'])) + esm = ShutdownModule(web3, Address(conf['MCD_ESM'])) + end = End(web3, Address(conf['MCD_END'])) + proxy_registry = ProxyRegistry(web3, Address(conf['PROXY_REGISTRY'])) + dss_proxy_actions = DssProxyActionsDsr(web3, Address(conf['PROXY_ACTIONS_DSR'])) + cdp_manager = CdpManager(web3, Address(conf['CDP_MANAGER'])) + dsr_manager = DsrManager(web3, Address(conf['DSR_MANAGER'])) + faucet = TokenFaucet(web3, Address(conf['FAUCET'])) if address_in_configs('FAUCET', conf) else None + + collaterals = {} + for name in DssDeployment.Config._infer_collaterals_from_addresses(conf.keys()): + ilk = vat.ilk(name[0].replace('_', '-')) + if name[1] == "ETH": + gem = DSEthToken(web3, Address(conf[name[1]])) + else: + gem = DSToken(web3, Address(conf[name[1]])) + + if name[1] in ['USDC', 'WBTC', 'TUSD', 'USDT', 'GUSD', 'RENBTC']: + adapter = GemJoin5(web3, Address(conf[f'MCD_JOIN_{name[0]}'])) + else: + adapter = GemJoin(web3, Address(conf[f'MCD_JOIN_{name[0]}'])) + + # PIP contract may be a DSValue, OSM, or bogus address. + pip_name = f'PIP_{name[1]}' + pip_address = Address(conf[pip_name]) if pip_name in conf and conf[pip_name] else None + val_name = f'VAL_{name[1]}' + val_address = Address(conf[val_name]) if val_name in conf and conf[val_name] else None + if pip_address: # Configure OSM as price source + pip = OSM(web3, pip_address) + elif val_address: # Configure price using DSValue + pip = DSValue(web3, val_address) + else: + pip = None + + auction = None + if f'MCD_FLIP_{name[0]}' in conf: + auction = Flipper(web3, Address(conf[f'MCD_FLIP_{name[0]}'])) + elif f'MCD_CLIP_{name[0]}' in conf: + auction = Clipper(web3, Address(conf[f'MCD_CLIP_{name[0]}'])) + + collateral = Collateral(ilk=ilk, gem=gem, adapter=adapter, auction=auction, pip=pip, vat=vat) + collaterals[ilk.name] = collateral + + return DssDeployment.Config(pause, vat, vow, jug, cat, dog, flapper, flopper, pot, + dai, dai_adapter, mkr, spotter, ds_chief, esm, end, + proxy_registry, dss_proxy_actions, cdp_manager, + dsr_manager, faucet, collaterals) + + @staticmethod + def _infer_collaterals_from_addresses(keys: []) -> List: + collaterals = [] + for key in keys: + match = re.search(r'MCD_[CF]LIP_(?!CALC)((\w+)_\w+)', key) + if match: + collaterals.append((match.group(1), match.group(2))) + continue + match = re.search(r'MCD_[CF]LIP_(?!CALC)(\w+)', key) + if match: + collaterals.append((match.group(1), match.group(1))) + + return collaterals + + # def to_dict(self) -> dict: + # conf_dict = { + # 'MCD_PAUSE': self.pause.address.address, + # 'MCD_VAT': self.vat.address.address, + # 'MCD_VOW': self.vow.address.address, + # 'MCD_JUG': self.jug.address.address, + # 'MCD_FLAP': self.flapper.address.address, + # 'MCD_FLOP': self.flopper.address.address, + # 'MCD_POT': self.pot.address.address, + # 'MCD_DAI': self.dai.address.address, + # 'MCD_JOIN_DAI': self.dai_join.address.address, + # 'MCD_GOV': self.mkr.address.address, + # 'MCD_SPOT': self.spotter.address.address, + # 'MCD_ADM': self.ds_chief.address.address, + # 'MCD_ESM': self.esm.address.address, + # 'MCD_END': self.end.address.address, + # 'PROXY_REGISTRY': self.proxy_registry.address.address, + # 'PROXY_ACTIONS_DSR': self.dss_proxy_actions.address.address, + # 'CDP_MANAGER': self.cdp_manager.address.address, + # 'DSR_MANAGER': self.dsr_manager.address.address + # } + + # if self.cat: + # conf_dict['MCD_CAT'] = self.cat.address.address + # if self.dog: + # conf_dict['MCD_DOG'] = self.dog.address.address + # if self.faucet: + # conf_dict['FAUCET'] = self.faucet.address.address + + # for collateral in self.collaterals.values(): + # match = re.search(r'(\w+)(?:-\w+)?', collateral.ilk.name) + # name = (collateral.ilk.name.replace('-', '_'), match.group(1)) + # conf_dict[name[1]] = collateral.gem.address.address + # if collateral.pip: + # conf_dict[f'PIP_{name[1]}'] = collateral.pip.address.address + # conf_dict[f'MCD_JOIN_{name[0]}'] = collateral.adapter.address.address + # if collateral.flipper: + # conf_dict[f'MCD_FLIP_{name[0]}'] = collateral.flipper.address.address + # elif collateral.clipper: + # conf_dict[f'MCD_CLIP_{name[0]}'] = collateral.clipper.address.address + + # return conf_dict + + # def to_json(self) -> str: + # return json.dumps(self.to_dict()) + + def __init__(self, web3: Web3, config: Config): + assert isinstance(web3, Web3) + assert isinstance(config, DssDeployment.Config) + + self.web3 = web3 + self.config = config + # self.pause = config.pause + # self.vat = config.vat + # self.vow = config.vow + # self.jug = config.jug + # self.cat = config.cat + # self.dog = config.dog + # self.flapper = config.flapper + # self.flopper = config.flopper + # self.pot = config.pot + # self.dai = config.dai + # self.dai_adapter = config.dai_join + # self.mkr = config.mkr + # self.collaterals = config.collaterals + # self.spotter = config.spotter + # self.ds_chief = config.ds_chief + # self.esm = config.esm + # self.end = config.end + # self.proxy_registry = config.proxy_registry + # self.dss_proxy_actions = config.dss_proxy_actions + # self.cdp_manager = config.cdp_manager + # self.dsr_manager = config.dsr_manager + # self.faucet = config.faucet + + @staticmethod + def from_json(web3: Web3, conf: str): + return DssDeployment(web3, DssDeployment.Config.from_json(web3, conf)) + + def to_json(self) -> str: + return self.config.to_json() + + # @staticmethod + # def from_node(web3: Web3): + # assert isinstance(web3, Web3) + + # network = DssDeployment.NETWORKS.get(web3.net.version, "testnet") + + # return DssDeployment.from_network(web3=web3, network=network) + + @staticmethod + def from_network(web3: Web3, network: str): + assert isinstance(web3, Web3) + assert isinstance(network, str) + + cwd = os.path.dirname(os.path.realpath(__file__)) + addresses_path = os.path.join(cwd, "../config", f"{network}-addresses.json") + + return DssDeployment.from_json(web3=web3, conf=open(addresses_path, "r").read()) + + # def approve_dai(self, usr: Address, **kwargs): + # """ + # Allows the user to draw Dai from and repay Dai to their CDPs. + + # Args + # usr: Recipient of Dai from one or more CDPs + # """ + # assert isinstance(usr, Address) + + # gas_price = kwargs['gas_price'] if 'gas_price' in kwargs else DefaultGasPrice() + # self.dai_adapter.approve(approval_function=hope_directly(from_address=usr, gas_price=gas_price), + # source=self.vat.address) + # self.dai.approve(self.dai_adapter.address).transact(from_address=usr, gas_price=gas_price) + + # def active_auctions(self) -> dict: + # flips = {} + # clips = {} + # for collateral in self.collaterals.values(): + # # Each collateral has it's own liquidation contract; add auctions from each. + # if collateral.flipper: + # flips[collateral.ilk.name] = collateral.flipper.active_auctions() + # elif collateral.clipper: + # clips[collateral.ilk.name] = collateral.clipper.active_auctions() + + # return { + # "flips": flips, + # "clips": clips, + # "flaps": self.flapper.active_auctions(), + # "flops": self.flopper.active_auctions() + # } + + def __repr__(self): + return f'DssDeployment({self.config.to_json()})' \ No newline at end of file diff --git a/chief_keeper/utils/address_utils.py b/chief_keeper/utils/address.py similarity index 100% rename from chief_keeper/utils/address_utils.py rename to chief_keeper/utils/address.py diff --git a/chief_keeper/utils/big_number.py b/chief_keeper/utils/big_number.py new file mode 100644 index 0000000..b19b565 --- /dev/null +++ b/chief_keeper/utils/big_number.py @@ -0,0 +1,412 @@ +# This file is part of Maker Keeper Framework. +# +# Copyright (C) 2017-2018 reverendus +# Copyright (C) 2018 bargst +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import math +from functools import total_ordering, reduce +from decimal import * + + +_context = Context(prec=1000, rounding=ROUND_DOWN) + + +@total_ordering +class Wad: + """Represents a number with 18 decimal places. + + `Wad` implements comparison, addition, subtraction, multiplication and division operators. Comparison, addition, + subtraction and division only work with other instances of `Wad`. Multiplication works with instances + of `Wad` and `Ray` and also with `int` numbers. The result of multiplication is always a `Wad`. + + `Wad`, along with `Ray`, are the two basic numeric types used by Maker contracts. + + Notes: + The internal representation of `Wad` is an unbounded integer, the last 18 digits of it being treated + as decimal places. It is similar to the representation used in Maker contracts (`uint128`). + """ + + def __init__(self, value): + """Creates a new Wad number. + + Args: + value: an instance of `Wad`, `Ray` or an integer. In case of an integer, the internal representation + of Maker contracts is used which means that passing `1` will create an instance of `Wad` + with a value of `0.000000000000000001'. + """ + if isinstance(value, Wad): + self.value = value.value + elif isinstance(value, Ray): + self.value = int((Decimal(value.value) // (Decimal(10)**Decimal(9))).quantize(1, context=_context)) + elif isinstance(value, Rad): + self.value = int((Decimal(value.value) // (Decimal(10)**Decimal(27))).quantize(1, context=_context)) + elif isinstance(value, int): + # assert(value >= 0) + self.value = value + else: + raise ArithmeticError + + @classmethod + def from_number(cls, number): + # assert(number >= 0) + pwr = Decimal(10) ** 18 + dec = Decimal(str(number)) * pwr + return Wad(int(dec.quantize(1, context=_context))) + + def __repr__(self): + return "Wad(" + str(self.value) + ")" + + def __str__(self): + tmp = str(self.value).zfill(19) + return (tmp[0:len(tmp)-18] + "." + tmp[len(tmp)-18:len(tmp)]).replace("-.", "-0.") + + def __add__(self, other): + if isinstance(other, Wad): + return Wad(self.value + other.value) + else: + raise ArithmeticError + + def __sub__(self, other): + if isinstance(other, Wad): + return Wad(self.value - other.value) + else: + raise ArithmeticError + + def __mod__(self, other): + if isinstance(other, Wad): + return Wad(self.value % other.value) + else: + raise ArithmeticError + + # z = cast((uint256(x) * y + WAD / 2) / WAD); + def __mul__(self, other): + if isinstance(other, Wad): + result = Decimal(self.value) * Decimal(other.value) / (Decimal(10) ** Decimal(18)) + return Wad(int(result.quantize(1, context=_context))) + elif isinstance(other, Ray): + result = Decimal(self.value) * Decimal(other.value) / (Decimal(10) ** Decimal(27)) + return Wad(int(result.quantize(1, context=_context))) + elif isinstance(other, Rad): + result = Decimal(self.value) * Decimal(other.value) / (Decimal(10) ** Decimal(45)) + return Wad(int(result.quantize(1, context=_context))) + elif isinstance(other, int): + return Wad(int((Decimal(self.value) * Decimal(other)).quantize(1, context=_context))) + else: + raise ArithmeticError + + def __truediv__(self, other): + if isinstance(other, Wad): + return Wad(int((Decimal(self.value) * (Decimal(10) ** Decimal(18)) / Decimal(other.value)).quantize(1, context=_context))) + else: + raise ArithmeticError + + def __abs__(self): + return Wad(abs(self.value)) + + def __eq__(self, other): + if isinstance(other, Wad): + return self.value == other.value + else: + raise ArithmeticError + + def __hash__(self): + return hash(self.value) + + def __lt__(self, other): + if isinstance(other, Wad): + return self.value < other.value + else: + raise ArithmeticError + + def __int__(self): + return int(self.value / 10**18) + + def __float__(self): + return self.value / 10**18 + + def __round__(self, ndigits: int = 0): + return Wad(round(self.value, -18 + ndigits)) + + def __sqrt__(self): + return Wad.from_number(math.sqrt(self.__float__())) + + @staticmethod + def min(*args): + """Returns the lower of the Wad values""" + return reduce(lambda x, y: x if x < y else y, args[1:], args[0]) + + @staticmethod + def max(*args): + """Returns the higher of the Wad values""" + return reduce(lambda x, y: x if x > y else y, args[1:], args[0]) + + +@total_ordering +class Ray: + """Represents a number with 27 decimal places. + + `Ray` implements comparison, addition, subtraction, multiplication and division operators. Comparison, addition, + subtraction and division only work with other instances of `Ray`. Multiplication works with instances + of `Ray` and `Wad` and also with `int` numbers. The result of multiplication is always a `Ray`. + + `Ray`, along with `Wad`, are the two basic numeric types used by Maker contracts. + + Notes: + The internal representation of `Ray` is an unbounded integer, the last 27 digits of it being treated + as decimal places. It is similar to the representation used in Maker contracts (`uint128`). + """ + + def __init__(self, value): + """Creates a new Ray number. + + Args: + value: an instance of `Ray`, `Wad` or an integer. In case of an integer, the internal representation + of Maker contracts is used which means that passing `1` will create an instance of `Ray` + with a value of `0.000000000000000000000000001'. + """ + if isinstance(value, Ray): + self.value = value.value + elif isinstance(value, Wad): + self.value = int((Decimal(value.value) * (Decimal(10)**Decimal(9))).quantize(1, context=_context)) + elif isinstance(value, Rad): + self.value = int((Decimal(value.value) / (Decimal(10)**Decimal(18))).quantize(1, context=_context)) + elif isinstance(value, int): + # assert(value >= 0) + self.value = value + else: + raise ArithmeticError + + @classmethod + def from_number(cls, number): + # assert(number >= 0) + pwr = Decimal(10) ** 27 + dec = Decimal(str(number)) * pwr + return Ray(int(dec.quantize(1, context=_context))) + + def __repr__(self): + return "Ray(" + str(self.value) + ")" + + def __str__(self): + tmp = str(self.value).zfill(28) + return (tmp[0:len(tmp)-27] + "." + tmp[len(tmp)-27:len(tmp)]).replace("-.", "-0.") + + def __add__(self, other): + if isinstance(other, Ray): + return Ray(self.value + other.value) + else: + raise ArithmeticError + + def __sub__(self, other): + if isinstance(other, Ray): + return Ray(self.value - other.value) + else: + raise ArithmeticError + + def __mod__(self, other): + if isinstance(other, Ray): + return Ray(self.value % other.value) + else: + raise ArithmeticError + + def __mul__(self, other): + if isinstance(other, Ray): + result = Decimal(self.value) * Decimal(other.value) / (Decimal(10) ** Decimal(27)) + return Ray(int(result.quantize(1, context=_context))) + elif isinstance(other, Wad): + result = Decimal(self.value) * Decimal(other.value) / (Decimal(10) ** Decimal(18)) + return Ray(int(result.quantize(1, context=_context))) + elif isinstance(other, Rad): + result = Decimal(self.value) * Decimal(other.value) / (Decimal(10) ** Decimal(45)) + return Ray(int(result.quantize(1, context=_context))) + elif isinstance(other, int): + return Ray(int((Decimal(self.value) * Decimal(other)).quantize(1, context=_context))) + else: + raise ArithmeticError + + def __truediv__(self, other): + if isinstance(other, Ray): + return Ray(int((Decimal(self.value) * (Decimal(10) ** Decimal(27)) / Decimal(other.value)).quantize(1, context=_context))) + else: + raise ArithmeticError + + def __abs__(self): + return Ray(abs(self.value)) + + def __eq__(self, other): + if isinstance(other, Ray): + return self.value == other.value + else: + raise ArithmeticError + + def __hash__(self): + return hash(self.value) + + def __lt__(self, other): + if isinstance(other, Ray): + return self.value < other.value + else: + raise ArithmeticError + + def __int__(self): + return int(self.value / 10**27) + + def __float__(self): + return self.value / 10**27 + + def __round__(self, ndigits: int = 0): + return Ray(round(self.value, -27 + ndigits)) + + def __sqrt__(self): + return Ray.from_number(math.sqrt(self.__float__())) + + @staticmethod + def min(*args): + """Returns the lower of the Ray values""" + return reduce(lambda x, y: x if x < y else y, args[1:], args[0]) + + @staticmethod + def max(*args): + """Returns the higher of the Ray values""" + return reduce(lambda x, y: x if x > y else y, args[1:], args[0]) + + +@total_ordering +class Rad: + """Represents a number with 45 decimal places. + + `Rad` implements comparison, addition, subtraction, multiplication and division operators. Comparison, addition, + subtraction and division only work with other instances of `Rad`. Multiplication works with instances + of `Rad`, `Ray and `Wad` and also with `int` numbers. The result of multiplication is always a `Rad`. + + `Rad` is rad is a new unit that exists to prevent precision loss in the core CDP engine of MCD. + + Notes: + The internal representation of `Rad` is an unbounded integer, the last 45 digits of it being treated + as decimal places. + """ + + def __init__(self, value): + """Creates a new Rad number. + + Args: + value: an instance of `Rad`, `Ray`, `Wad` or an integer. In case of an integer, the internal representation + of Maker contracts is used which means that passing `1` will create an instance of `Rad` + with a value of `0.000000000000000000000000000000000000000000001'. + """ + if isinstance(value, Rad): + self.value = value.value + elif isinstance(value, Ray): + self.value = int((Decimal(value.value) * (Decimal(10)**Decimal(18))).quantize(1, context=_context)) + elif isinstance(value, Wad): + self.value = int((Decimal(value.value) * (Decimal(10)**Decimal(27))).quantize(1, context=_context)) + elif isinstance(value, int): + # assert(value >= 0) + self.value = value + else: + raise ArithmeticError + + @classmethod + def from_number(cls, number): + # assert(number >= 0) + pwr = Decimal(10) ** 45 + dec = Decimal(str(number)) * pwr + return Rad(int(dec.quantize(1, context=_context))) + + def __repr__(self): + return "Rad(" + str(self.value) + ")" + + def __str__(self): + tmp = str(self.value).zfill(46) + return (tmp[0:len(tmp)-45] + "." + tmp[len(tmp)-45:len(tmp)]).replace("-.", "-0.") + + def __add__(self, other): + if isinstance(other, Rad): + return Rad(self.value + other.value) + else: + raise ArithmeticError + + def __sub__(self, other): + if isinstance(other, Rad): + return Rad(self.value - other.value) + else: + raise ArithmeticError + + def __mod__(self, other): + if isinstance(other, Rad): + return Rad(self.value % other.value) + else: + raise ArithmeticError + + def __mul__(self, other): + if isinstance(other, Rad): + result = Decimal(self.value) * Decimal(other.value) / (Decimal(10) ** Decimal(45)) + return Rad(int(result.quantize(1, context=_context))) + elif isinstance(other, Ray): + result = Decimal(self.value) * Decimal(other.value) / (Decimal(10) ** Decimal(27)) + return Rad(int(result.quantize(1, context=_context))) + elif isinstance(other, Wad): + result = Decimal(self.value) * Decimal(other.value) / (Decimal(10) ** Decimal(18)) + return Rad(int(result.quantize(1, context=_context))) + elif isinstance(other, int): + return Rad(int((Decimal(self.value) * Decimal(other)).quantize(1, context=_context))) + else: + raise ArithmeticError + + def __truediv__(self, other): + if isinstance(other, Rad): + return Rad(int((Decimal(self.value) * (Decimal(10) ** Decimal(45)) / Decimal(other.value)).quantize(1, context=_context))) + else: + raise ArithmeticError + + def __abs__(self): + return Rad(abs(self.value)) + + def __eq__(self, other): + if isinstance(other, Rad): + return self.value == other.value + else: + raise ArithmeticError + + def __hash__(self): + return hash(self.value) + + def __lt__(self, other): + if isinstance(other, Rad): + return self.value < other.value + else: + raise ArithmeticError + + def __int__(self): + return int(self.value / 10**45) + + def __float__(self): + return self.value / 10**45 + + def __round__(self, ndigits: int = 0): + return Rad(round(self.value, -45 + ndigits)) + + def __sqrt__(self): + return Rad.from_number(math.sqrt(self.__float__())) + + @staticmethod + def min(*args): + """Returns the lower of the Rad values""" + return reduce(lambda x, y: x if x < y else y, args[1:], args[0]) + + @staticmethod + def max(*args): + """Returns the higher of the Rad values""" + return reduce(lambda x, y: x if x > y else y, args[1:], args[0]) diff --git a/chief_keeper/utils/blockchain_utils.py b/chief_keeper/utils/blockchain.py similarity index 99% rename from chief_keeper/utils/blockchain_utils.py rename to chief_keeper/utils/blockchain.py index 00eb0fd..a00c5cb 100644 --- a/chief_keeper/utils/blockchain_utils.py +++ b/chief_keeper/utils/blockchain.py @@ -1,6 +1,6 @@ # This file is part of the Maker Keeper Framework. - +# # It contains utility functions to handle the initialization and connection # to an Ethereum blockchain node. The functions include methods for connecting # to primary and backup nodes, and configuring the Web3 connection. diff --git a/chief_keeper/utils/contract.py b/chief_keeper/utils/contract.py new file mode 100644 index 0000000..da5b0ac --- /dev/null +++ b/chief_keeper/utils/contract.py @@ -0,0 +1,90 @@ +# This file is part of Maker Keeper Framework. +# +# Copyright (C) 2017-2018 reverendus +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import json +import logging + +import eth_utils +import pkg_resources + +from web3 import Web3 + +from chief_keeper.utils.address import Address + +from chief_keeper.utils.utils import bytes_to_hexstring, is_contract_at + +class Contract: + logger = logging.getLogger() + + @staticmethod + def _deploy(web3: Web3, abi: list, bytecode: str, args: list) -> Address: + assert(isinstance(web3, Web3)) + assert(isinstance(abi, list)) + assert(isinstance(bytecode, str)) + assert(isinstance(args, list)) + + contract = web3.eth.contract(abi=abi, bytecode=bytecode) + tx_hash = contract.constructor(*args).transact( + transaction={'from': eth_utils.to_checksum_address(web3.eth.defaultAccount)}) + receipt = web3.eth.getTransactionReceipt(tx_hash) + return Address(receipt['contractAddress']) + + @staticmethod + def _get_contract(web3: Web3, abi: list, address: Address): + assert(isinstance(web3, Web3)) + assert(isinstance(abi, list)) + assert(isinstance(address, Address)) + + if not is_contract_at(web3, address): + raise Exception(f"No contract found at {address}") + + return web3.eth.contract(abi=abi)(address=address.address) + + def _past_events(self, contract, event, cls, number_of_past_blocks, event_filter) -> list: + block_number = contract.web3.eth.blockNumber + return self._past_events_in_block_range(contract, event, cls, max(block_number-number_of_past_blocks, 0), + block_number, event_filter) + + def _past_events_in_block_range(self, contract, event, cls, from_block, to_block, event_filter) -> list: + assert(isinstance(from_block, int)) + assert(isinstance(to_block, int)) + assert(isinstance(event_filter, dict) or (event_filter is None)) + + def _event_callback(cls, past): + def callback(log): + if past: + self.logger.debug(f"Past event {log['event']} discovered, block_number={log['blockNumber']}," + f" tx_hash={bytes_to_hexstring(log['transactionHash'])}") + else: + self.logger.debug(f"Event {log['event']} discovered, block_number={log['blockNumber']}," + f" tx_hash={bytes_to_hexstring(log['transactionHash'])}") + return cls(log) + + return callback + + result = contract.events[event].createFilter(fromBlock=from_block, toBlock=to_block, + argument_filters=event_filter).get_all_entries() + + return list(map(_event_callback(cls, True), result)) + + @staticmethod + def _load_abi(package, resource) -> list: + return json.loads(pkg_resources.resource_string(package, resource)) + + @staticmethod + def _load_bin(package, resource) -> str: + return str(pkg_resources.resource_string(package, resource), "utf-8") diff --git a/chief_keeper/utils/register_keys.py b/chief_keeper/utils/register_keys.py index cd98d1a..3b681f9 100644 --- a/chief_keeper/utils/register_keys.py +++ b/chief_keeper/utils/register_keys.py @@ -30,8 +30,7 @@ from eth_account import Account from web3 import Web3 from web3.middleware import construct_sign_and_send_raw_middleware - -from .address_utils import Address +from chief_keeper.utils.address import Address _registered_accounts = {} diff --git a/chief_keeper/utils/transact.py b/chief_keeper/utils/transact.py new file mode 100644 index 0000000..e69cd75 --- /dev/null +++ b/chief_keeper/utils/transact.py @@ -0,0 +1,431 @@ +# This file is part of Maker Keeper Framework. +# +# Copyright (C) 2017-2018 reverendus +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import asyncio + +import logging +import sys +import time +from typing import Optional + +from web3 import Web3 +from web3.exceptions import TransactionNotFound + +from pymaker.gas import DefaultGasPrice, GasStrategy +from chief_keeper.utils.utils import synchronize, bytes_to_hexstring + + +class Transact: + """Represents an Ethereum transaction before it gets executed.""" + + logger = logging.getLogger() + gas_estimate_for_bad_txs = None + + def __init__(self, + origin: Optional[object], + web3: Web3, + abi: Optional[list], + address: Address, + contract: Optional[object], + function_name: Optional[str], + parameters: Optional[list], + extra: Optional[dict] = None, + result_function=None): + assert(isinstance(origin, object) or (origin is None)) + assert(isinstance(web3, Web3)) + assert(isinstance(abi, list) or (abi is None)) + assert(isinstance(address, Address)) + assert(isinstance(contract, object) or (contract is None)) + assert(isinstance(function_name, str) or (function_name is None)) + assert(isinstance(parameters, list) or (parameters is None)) + assert(isinstance(extra, dict) or (extra is None)) + assert(callable(result_function) or (result_function is None)) + + self.origin = origin + self.web3 = web3 + self.abi = abi + self.address = address + self.contract = contract + self.function_name = function_name + self.parameters = parameters + self.extra = extra + self.result_function = result_function + self.initial_time = None + self.status = TransactStatus.NEW + self.nonce = None + self.replaced = False + self.gas_strategy = None + self.gas_fees_last = None + self.tx_hashes = [] + + def _get_receipt(self, transaction_hash: str) -> Optional[Receipt]: + try: + raw_receipt = self.web3.eth.getTransactionReceipt(transaction_hash) + if raw_receipt is not None and raw_receipt['blockNumber'] is not None: + receipt = Receipt(raw_receipt) + receipt.result = self.result_function(receipt) if self.result_function is not None else None + return receipt + except (TransactionNotFound, ValueError): + self.logger.debug(f"Transaction {transaction_hash} not found (may have been dropped/replaced)") + return None + + def _as_dict(self, dict_or_none) -> dict: + if dict_or_none is None: + return {} + else: + return dict(**dict_or_none) + + def _gas(self, gas_estimate: int, **kwargs) -> int: + if 'gas' in kwargs and 'gas_buffer' in kwargs: + raise Exception('"gas" and "gas_buffer" keyword arguments may not be specified at the same time') + + if 'gas' in kwargs: + return kwargs['gas'] + elif 'gas_buffer' in kwargs: + return gas_estimate + kwargs['gas_buffer'] + else: + return gas_estimate + 100000 + + def _gas_fees(self, seconds_elapsed: int, gas_strategy: GasStrategy) -> dict: + assert isinstance(seconds_elapsed, int) + assert isinstance(gas_strategy, GasStrategy) + + supports_eip1559 = _get_endpoint_behavior(self.web3).supports_eip1559 + gas_price = gas_strategy.get_gas_price(seconds_elapsed) + gas_feecap, gas_tip = gas_strategy.get_gas_fees(seconds_elapsed) if supports_eip1559 else (None, None) + + if supports_eip1559 and gas_feecap and gas_tip: # prefer type 2 TXes + params = {'maxFeePerGas': gas_feecap, 'maxPriorityFeePerGas': gas_tip} + elif gas_price: # fallback to type 0 if not supported or params not specified + params = {'gasPrice': gas_price} + else: # let the node determine gas + params = {} + return params + + def _gas_exceeds_replacement_threshold(self, prev_gas_params: dict, curr_gas_params: dict): + # NOTE: Experimentally (on OpenEthereum), I discovered a type 0 TX cannot be replaced with a type 2 TX. + + # Determine if a type 0 transaction would be replaced + if 'gasPrice' in prev_gas_params and 'gasPrice' in curr_gas_params: + return curr_gas_params['gasPrice'] > prev_gas_params['gasPrice'] * 1.125 + # Determine if a type 2 transaction would be replaced + elif 'maxFeePerGas' in prev_gas_params and 'maxFeePerGas' in curr_gas_params: + # This is how it should work, but doesn't; read here: https://github.com/ethereum/go-ethereum/issues/23311 + # base_fee = int(self.web3.eth.get_block('pending')['baseFeePerGas']) + # prev_effective_price = base_fee + prev_gas_params['maxPriorityFeePerGas'] + # curr_effective_price = base_fee + curr_gas_params['maxPriorityFeePerGas'] + # print(f"base={base_fee} prev_eff={prev_effective_price} curr_eff={curr_effective_price}") + # return curr_effective_price > prev_effective_price * 1.125 + feecap_bumped = curr_gas_params['maxFeePerGas'] > prev_gas_params['maxFeePerGas'] * 1.125 + tip_bumped = curr_gas_params['maxPriorityFeePerGas'] > prev_gas_params['maxPriorityFeePerGas'] * 1.125 + # print(f"feecap={curr_gas_params['maxFeePerGas']} tip={curr_gas_params['maxPriorityFeePerGas']} " + # f"feecap_bumped={feecap_bumped} tip_bumped={tip_bumped}") + return feecap_bumped and tip_bumped + else: # Replacement impossible if no parameters were offered + return False + + def _func(self, from_account: str, gas: int, gas_price_params: dict, nonce: Optional[int]): + assert isinstance(from_account, str) + assert isinstance(gas_price_params, dict) + assert isinstance(nonce, int) or nonce is None + + nonce_dict = {'nonce': nonce} if nonce is not None else {} + transaction_params = {**{'from': from_account, 'gas': gas}, + **gas_price_params, + **nonce_dict, + **self._as_dict(self.extra)} + if self.contract is not None: + if self.function_name is None: + + return bytes_to_hexstring(self.web3.eth.send_transaction({**transaction_params, + **{'to': self.address.address, + 'data': self.parameters[0]}})) + else: + return bytes_to_hexstring(self._contract_function().transact(transaction_params)) + else: + return bytes_to_hexstring(self.web3.eth.send_transaction({**transaction_params, + **{'to': self.address.address}})) + + def _contract_function(self): + if '(' in self.function_name: + function_factory = self.contract.get_function_by_signature(self.function_name) + + else: + function_factory = self.contract.get_function_by_name(self.function_name) + + return function_factory(*self.parameters) + + def _interlocked_choose_nonce_and_send(self, from_account: str, gas: int, gas_fees: dict): + global next_nonce + assert isinstance(from_account, str) # address of the sender + assert isinstance(gas, int) # gas amount + assert isinstance(gas_fees, dict) # gas fee parameters + + # We need the lock in order to not try to send two transactions with the same nonce. + transaction_lock.acquire() + # self.logger.debug(f"lock {id(transaction_lock)} acquired") + + if from_account not in next_nonce: + # logging.debug(f"Initializing nonce for {from_account}") + next_nonce[from_account] = self.web3.eth.getTransactionCount(from_account, block_identifier='pending') + + try: + if self.nonce is None: + nonce_calc = _get_endpoint_behavior(self.web3).nonce_calc + if nonce_calc == NonceCalculation.PARITY_NEXTNONCE: + self.nonce = int(self.web3.manager.request_blocking("parity_nextNonce", [from_account]), 16) + elif nonce_calc == NonceCalculation.TX_COUNT: + self.nonce = self.web3.eth.getTransactionCount(from_account, block_identifier='pending') + elif nonce_calc == NonceCalculation.SERIAL: + tx_count = self.web3.eth.getTransactionCount(from_account, block_identifier='pending') + next_serial = next_nonce[from_account] + self.nonce = max(tx_count, next_serial) + elif nonce_calc == NonceCalculation.PARITY_SERIAL: + tx_count = int(self.web3.manager.request_blocking("parity_nextNonce", [from_account]), 16) + next_serial = next_nonce[from_account] + self.nonce = max(tx_count, next_serial) + next_nonce[from_account] = self.nonce + 1 + # self.logger.debug(f"Chose nonce {self.nonce} with tx_count={tx_count} and " + # f"next_serial={next_serial}; next is {next_nonce[from_account]}") + + # Trap replacement while original is holding the lock awaiting nonce assignment + if self.replaced: + self.logger.info(f"Transaction {self.name()} with nonce={self.nonce} was replaced") + return None + + tx_hash = self._func(from_account, gas, gas_fees, self.nonce) + self.tx_hashes.append(tx_hash) + + self.logger.info(f"Sent transaction {self.name()} with nonce={self.nonce}, gas={gas}," + f" gas_fees={gas_fees if gas_fees else 'default'}" + f" (tx_hash={tx_hash})") + except Exception as e: + self.logger.warning(f"Failed to send transaction {self.name()} with nonce={self.nonce}, gas={gas}," + f" gas_fees={gas_fees if gas_fees else 'default'} ({e})") + + if len(self.tx_hashes) == 0: + raise + finally: + transaction_lock.release() + # self.logger.debug(f"lock {id(transaction_lock)} released with next_nonce={next_nonce[from_account]}") + + def name(self) -> str: + """Returns the nicely formatted name of this pending Ethereum transaction. + + Returns: + Nicely formatted name of this pending Ethereum transaction. + """ + if self.origin: + def format_parameter(parameter): + if isinstance(parameter, bytes): + return bytes_to_hexstring(parameter) + else: + return parameter + + formatted_parameters = str(list(map(format_parameter, self.parameters))).lstrip("[").rstrip("]") + name = f"{repr(self.origin)}.{self.function_name}({formatted_parameters})" + else: + name = f"Regular transfer to {self.address}" + + return name if self.extra is None else name + f" with {self.extra}" + + def estimated_gas(self, from_address: Address) -> int: + """Return an estimated amount of gas which will get consumed by this Ethereum transaction. + + May throw an exception if the actual transaction will fail as well. + + Args: + from_address: Address to simulate sending the transaction from. + + Returns: + Amount of gas as an integer. + """ + assert(isinstance(from_address, Address)) + + if self.contract is not None: + if self.function_name is None: + return self.web3.eth.estimateGas({**self._as_dict(self.extra), **{'from': from_address.address, + 'to': self.address.address, + 'data': self.parameters[0]}}) + + else: + estimate = self._contract_function() \ + .estimateGas({**self._as_dict(self.extra), **{'from': from_address.address}}) + + else: + estimate = 21000 + + return estimate + + def transact(self, **kwargs) -> Optional[Receipt]: + """Executes the Ethereum transaction synchronously. + + Executes the Ethereum transaction synchronously. The method will block until the + transaction gets mined i.e. it will return when either the transaction execution + succeeded or failed. In case of the former, a :py:class:`pymaker.Receipt` + object will be returned. + + Out-of-gas exceptions are automatically recognized as transaction failures. + + Allowed keyword arguments are: `from_address`, `replace`, `gas`, `gas_buffer`, `gas_price`. + `gas_price` needs to be an instance of a class inheriting from :py:class:`pymaker.gas.GasPrice`. + `from_address` needs to be an instance of :py:class:`pymaker.Address`. + + The `gas` keyword argument is the gas limit for the transaction, whereas `gas_buffer` + specifies how much gas should be added to the estimate. They can not be present + at the same time. If none of them are present, a default buffer is added to the estimate. + + Returns: + A :py:class:`pymaker.Receipt` object if the transaction invocation was successful. + `None` otherwise. + """ + return synchronize([self.transact_async(**kwargs)])[0] + + @_track_status + async def transact_async(self, **kwargs) -> Optional[Receipt]: + """Executes the Ethereum transaction asynchronously. + + Executes the Ethereum transaction asynchronously. The method will return immediately. + Ultimately, its future value will become either a :py:class:`pymaker.Receipt` or `None`, + depending on whether the transaction execution was successful or not. + + Out-of-gas exceptions are automatically recognized as transaction failures. + + Allowed keyword arguments are: `from_address`, `replace`, `gas`, `gas_buffer`, `gas_price`. + `gas_price` needs to be an instance of a class inheriting from :py:class:`pymaker.gas.GasPrice`. + + The `gas` keyword argument is the gas limit for the transaction, whereas `gas_buffer` + specifies how much gas should be added to the estimate. They can not be present + at the same time. If none of them are present, a default buffer is added to the estimate. + + Returns: + A future value of either a :py:class:`pymaker.Receipt` object if the transaction + invocation was successful, or `None` if it failed. + """ + + self.initial_time = time.time() + unknown_kwargs = set(kwargs.keys()) - {'from_address', 'replace', 'gas', 'gas_buffer', 'gas_strategy'} + if len(unknown_kwargs) > 0: + raise ValueError(f"Unknown kwargs: {unknown_kwargs}") + + # Get the account from which the transaction will be submitted + from_account = kwargs['from_address'].address if ('from_address' in kwargs) else self.web3.eth.defaultAccount + + # First we try to estimate the gas usage of the transaction. If gas estimation fails + # it means there is no point in sending the transaction, thus we fail instantly and + # do not increment the nonce. If the estimation is successful, we pass the calculated + # gas value (plus some `gas_buffer`) to the subsequent `transact` calls so it does not + # try to estimate it again. + try: + gas_estimate = self.estimated_gas(Address(from_account)) + except: + if Transact.gas_estimate_for_bad_txs: + self.logger.warning(f"Transaction {self.name()} will fail, submitting anyway") + gas_estimate = Transact.gas_estimate_for_bad_txs + else: + self.logger.warning(f"Transaction {self.name()} will fail, refusing to send ({sys.exc_info()[1]})") + return None + + # Get or calculate `gas`. Get `gas_strategy`, which in fact refers to a gas pricing algorithm. + gas = self._gas(gas_estimate, **kwargs) + self.gas_strategy = kwargs['gas_strategy'] if ('gas_strategy' in kwargs) else DefaultGasPrice() + assert(isinstance(self.gas_strategy, GasStrategy)) + + # Get the transaction this one is supposed to replace. + # If there is one, try to borrow the nonce from it as long as that transaction isn't finished. + replaced_tx = kwargs['replace'] if ('replace' in kwargs) else None + if replaced_tx is not None: + while replaced_tx.nonce is None and replaced_tx.status != TransactStatus.FINISHED: + await asyncio.sleep(0.25) + + replaced_tx.replaced = True + self.nonce = replaced_tx.nonce + # Gas should be calculated from the original time of submission + self.initial_time = replaced_tx.initial_time if replaced_tx.initial_time else time.time() + # Use gas strategy from the original transaction if one was not provided + if 'gas_strategy' not in kwargs: + self.gas_strategy = replaced_tx.gas_strategy if replaced_tx.gas_strategy else DefaultGasPrice() + self.gas_fees_last = replaced_tx.gas_fees_last + # Detain replacement until gas strategy produces a price acceptable to the node + if replaced_tx.tx_hashes: + most_recent_tx = replaced_tx.tx_hashes[-1] + self.tx_hashes = [most_recent_tx] + + while True: + seconds_elapsed = int(time.time() - self.initial_time) + gas_fees = self._gas_fees(seconds_elapsed, self.gas_strategy) + + # CAUTION: if transact_async is called rapidly, we will hammer the node with these JSON-RPC requests + if self.nonce is not None and self.web3.eth.getTransactionCount(from_account) > self.nonce: + # Check if any transaction sent so far has been mined (has a receipt). + # If it has, we return either the receipt (if if was successful) or `None`. + for attempt in range(1, 11): + if self.replaced: + self.logger.info(f"Transaction with nonce={self.nonce} was replaced with a newer transaction") + return None + + for tx_hash in self.tx_hashes: + receipt = self._get_receipt(tx_hash) + if receipt: + if receipt.successful: + # CAUTION: If original transaction is being replaced, this will print details of the + # replacement transaction even if the receipt was generated from the original. + self.logger.info(f"Transaction {self.name()} was successful (tx_hash={tx_hash})") + return receipt + else: + self.logger.warning(f"Transaction {self.name()} mined successfully but generated no single" + f" log entry, assuming it has failed (tx_hash={tx_hash})") + return None + + self.logger.debug(f"No receipt found in attempt #{attempt}/10 (nonce={self.nonce}," + f" getTransactionCount={self.web3.eth.getTransactionCount(from_account)})") + + await asyncio.sleep(0.5) + + # If we can not find a mined receipt but at the same time we know last used nonce + # has increased, then it means that the transaction we tried to send failed. + self.logger.warning(f"Transaction {self.name()} has been overridden by another transaction" + f" with the same nonce, which means it has failed") + return None + + # Trap replacement after the tx has entered the mempool and before it has been mined + if self.replaced: + self.logger.info(f"Attempting to replace transaction {self.name()} with nonce={self.nonce}") + return None + + # Send a transaction if: + # - no transaction has been sent yet, or + # - the requested gas price has changed enough since the last transaction has been sent + # - the gas price on a replacement has sufficiently exceeded that of the original transaction + transaction_was_sent = len(self.tx_hashes) > 0 or (replaced_tx is not None and len(replaced_tx.tx_hashes) > 0) + if not transaction_was_sent or (self.gas_fees_last and self._gas_exceeds_replacement_threshold(self.gas_fees_last, gas_fees)): + self.gas_fees_last = gas_fees + self._interlocked_choose_nonce_and_send(from_account, gas, gas_fees) + await asyncio.sleep(0.25) + + def invocation(self) -> Invocation: + """Returns the `Invocation` object for this pending Ethereum transaction. + + The :py:class:`pymaker.Invocation` object may be used with :py:class:`pymaker.transactional.TxManager` + to invoke multiple contract calls in one Ethereum transaction. + + Please see :py:class:`pymaker.transactional.TxManager` documentation for more details. + + Returns: + :py:class:`pymaker.Invocation` object for this pending Ethereum transaction. + """ + return Invocation(self.address, Calldata(self._contract_function()._encode_transaction_data())) diff --git a/chief_keeper/utils/utils.py b/chief_keeper/utils/utils.py new file mode 100644 index 0000000..28243a9 --- /dev/null +++ b/chief_keeper/utils/utils.py @@ -0,0 +1,96 @@ +# This file is part of Maker Keeper Framework. +# +# Copyright (C) 2017-2018 reverendus +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import asyncio + +from web3 import Web3 + +from chief_keeper.utils.big_number import Wad + + +def chain(web3: Web3) -> str: + block_0 = web3.eth.getBlock(0)['hash'] + if block_0 == "0xd4e56740f876aef8c010b86a40d5f56745a118d0906a34e69aec8c0db1cb8fa3": + return "ethlive" + elif block_0 == "0xa3c565fc15c7478862d50ccd6561e3c06b24cc509bf388941c25ea985ce32cb9": + return "kovan" + elif block_0 == "0x41941023680923e0fe4d74a34bdac8141f2540e3ae90623718e47d66d1ca4a2d": + return "ropsten" + elif block_0 == "0x0cd786a2425d16f152c658316c423e6ce1181e15c3295826d7c9904cba9ce303": + return "morden" + else: + return "unknown" + + +def http_response_summary(response) -> str: + text = response.text.replace('\r', '').replace('\n', '')[:2048] + return f"{response.status_code} {response.reason} ({text})" + + +# CAUTION: Used by Transact class, this breaks applications running their own asyncio event loop. +def synchronize(futures) -> list: + if len(futures) > 0: + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(asyncio.gather(*futures, loop=loop)) + finally: + loop.close() + else: + return [] + + +def eth_balance(web3: Web3, address) -> Wad: + return Wad(web3.eth.getBalance(address.address)) + + +def is_contract_at(web3: Web3, address): + code = web3.eth.getCode(address.address) + return (code is not None) and (code != "0x") and (code != "0x0") and (code != b"\x00") and (code != b"") + + +def int_to_bytes32(value: int) -> bytes: + assert(isinstance(value, int)) + return value.to_bytes(32, byteorder='big') + + +def bytes_to_int(value) -> int: + if isinstance(value, bytes) or isinstance(value, bytearray): + return int.from_bytes(value, byteorder='big') + elif isinstance(value, str): + b = bytearray() + b.extend(map(ord, value)) + return int.from_bytes(b, byteorder='big') + else: + raise AssertionError + + +def bytes_to_hexstring(value) -> str: + if isinstance(value, bytes) or isinstance(value, bytearray): + return "0x" + "".join(map(lambda b: format(b, "02x"), value)) + elif isinstance(value, str): + b = bytearray() + b.extend(map(ord, value)) + return "0x" + "".join(map(lambda b: format(b, "02x"), b)) + else: + raise AssertionError + + +def hexstring_to_bytes(value: str) -> bytes: + assert(isinstance(value, str)) + assert(value.startswith("0x")) + return Web3.toBytes(hexstr=value) +