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)
+