def get_underwater_urns(self, ilks: List) -> List[Urn]: """ With all urns every frobbed, compile and return a list urns that are under-collateralized up to 100% """ underwater_urns = [] for ilk in ilks: urn_history = UrnHistory(self.web3, self.dss, ilk, self.deployment_block, self.arguments.vulcanize_endpoint, self.arguments.vulcanize_key) urns = urn_history.get_urns() self.logger.info(f'Collected {len(urns)} from {ilk}') i = 0 for urn in urns.values(): urn.ilk = self.dss.vat.ilk(urn.ilk.name) mat = self.dss.spotter.mat(urn.ilk) usdDebt = Ray(urn.art) * urn.ilk.rate usdCollateral = Ray(urn.ink) * urn.ilk.spot * mat # Check if underwater -> urn.art * ilk.rate > urn.ink * ilk.spot * spotter.mat[ilk] if usdDebt > usdCollateral: underwater_urns.append(urn) i += 1 if i % 100 == 0: self.logger.info(f'Processed {i} urns of {ilk.name}') return underwater_urns
class AuctionKeeper: logger = logging.getLogger() def __init__(self, args: list, **kwargs): parser = argparse.ArgumentParser(prog='auction-keeper') parser.add_argument("--rpc-host", type=str, default="localhost", help="JSON-RPC host (default: `localhost')") parser.add_argument("--rpc-port", type=int, default=8545, help="JSON-RPC port (default: `8545')") parser.add_argument("--rpc-timeout", type=int, default=10, help="JSON-RPC timeout (in seconds, default: 10)") parser.add_argument( "--eth-from", type=str, required=True, help="Ethereum account from which to send transactions") parser.add_argument( "--eth-key", type=str, nargs='*', help= "Ethereum private key(s) to use (e.g. 'key_file=aaa.json,pass_file=aaa.pass')" ) parser.add_argument('--type', type=str, choices=['flip', 'flap', 'flop'], help="Auction type in which to participate") parser.add_argument( '--ilk', type=str, help= "Name of the collateral type for a flip keeper (e.g. 'ETH-B', 'ZRX-A'); " "available collateral types can be found at the left side of the CDP Portal" ) parser.add_argument( '--bid-only', dest='create_auctions', action='store_false', help="Do not take opportunities to create new auctions") parser.add_argument( '--max-auctions', type=int, default=100, help="Maximum number of auctions to simultaneously interact with, " "used to manage OS and hardware limitations") parser.add_argument( '--min-flip-lot', type=float, default=0, help="Minimum lot size to create or bid upon a flip auction") parser.add_argument( "--vulcanize-endpoint", type=str, help= "When specified, frob history will be queried from a VulcanizeDB lite node, " "reducing load on the Ethereum node for flip auctions") parser.add_argument( '--from-block', type=int, help= "Starting block from which to look at history (set to block where MCD was deployed)" ) parser.add_argument( '--vat-dai-target', type=float, help="Amount of Dai to keep in the Vat contract (e.g. 2000)") parser.add_argument( '--keep-dai-in-vat-on-exit', dest='exit_dai_on_shutdown', action='store_false', help= "Retain Dai in the Vat on exit, saving gas when restarting the keeper" ) parser.add_argument('--keep-gem-in-vat-on-exit', dest='exit_gem_on_shutdown', action='store_false', help="Retain collateral in the Vat on exit") parser.add_argument( "--model", type=str, required=True, nargs='+', help="Commandline to use in order to start the bidding model") parser.add_argument("--debug", dest='debug', action='store_true', help="Enable debug output") self.arguments = parser.parse_args(args) # Configure connection to the chain if self.arguments.rpc_host.startswith("http"): endpoint_uri = f"{self.arguments.rpc_host}:{self.arguments.rpc_port}" else: # Should probably default this to use TLS, but I don't want to break existing configs endpoint_uri = f"http://{self.arguments.rpc_host}:{self.arguments.rpc_port}" self.web3: Web3 = kwargs['web3'] if 'web3' in kwargs else Web3( HTTPProvider( endpoint_uri=endpoint_uri, request_kwargs={"timeout": self.arguments.rpc_timeout})) self.web3.eth.defaultAccount = self.arguments.eth_from register_keys(self.web3, self.arguments.eth_key) self.our_address = Address(self.arguments.eth_from) # Check configuration for retrieving urns/bites if self.arguments.type == 'flip' and self.arguments.create_auctions \ and self.arguments.from_block is None and self.arguments.vulcanize_endpoint is None: raise RuntimeError( "Either --from-block or --vulcanize-endpoint must be specified to kick off " "flip auctions") if self.arguments.type == 'flip' and not self.arguments.ilk: raise RuntimeError( "--ilk must be supplied when configuring a flip keeper") if self.arguments.type == 'flop' and self.arguments.create_auctions \ and self.arguments.from_block is None: raise RuntimeError( "--from-block must be specified to kick off flop auctions") # Configure core and token contracts mcd = DssDeployment.from_node(web3=self.web3) self.vat = mcd.vat self.cat = mcd.cat self.vow = mcd.vow self.mkr = mcd.mkr self.dai_join = mcd.dai_adapter if self.arguments.type == 'flip': self.collateral = mcd.collaterals[self.arguments.ilk] self.ilk = self.collateral.ilk self.gem_join = self.collateral.adapter else: self.collateral = None self.ilk = None self.gem_join = None # Configure auction contracts self.flipper = self.collateral.flipper if self.arguments.type == 'flip' else None self.flapper = mcd.flapper if self.arguments.type == 'flap' else None self.flopper = mcd.flopper if self.arguments.type == 'flop' else None self.urn_history = None if self.flipper: self.min_flip_lot = Wad.from_number(self.arguments.min_flip_lot) self.strategy = FlipperStrategy(self.flipper, self.min_flip_lot) self.urn_history = UrnHistory(self.web3, mcd, self.ilk, self.arguments.from_block, self.arguments.vulcanize_endpoint) elif self.flapper: self.strategy = FlapperStrategy(self.flapper, self.mkr.address) elif self.flopper: self.strategy = FlopperStrategy(self.flopper) else: raise RuntimeError("Please specify auction type") # Create the collection used to manage auctions relevant to this keeper self.auctions = Auctions( flipper=self.flipper.address if self.flipper else None, flapper=self.flapper.address if self.flapper else None, flopper=self.flopper.address if self.flopper else None, model_factory=ModelFactory(' '.join(self.arguments.model))) self.auctions_lock = threading.Lock() self.dead_auctions = set() self.vat_dai_target = Wad.from_number(self.arguments.vat_dai_target) if \ self.arguments.vat_dai_target is not None else None logging.basicConfig( format='%(asctime)-15s %(levelname)-8s %(message)s', level=(logging.DEBUG if self.arguments.debug else logging.INFO)) # reduce logspew logging.getLogger('urllib3').setLevel(logging.INFO) logging.getLogger("web3").setLevel(logging.INFO) logging.getLogger("asyncio").setLevel(logging.INFO) logging.getLogger("requests").setLevel(logging.INFO) def main(self): def seq_func(check_func: callable): assert callable(check_func) if self.arguments.create_auctions: try: check_func() except (RequestException, ConnectionError, ValueError, AttributeError): logging.exception( "Error checking for opportunities to start an auction") try: self.check_all_auctions() except (RequestException, ConnectionError, ValueError, AttributeError): logging.exception("Error checking auction states") with Lifecycle(self.web3) as lifecycle: lifecycle.on_startup(self.startup) lifecycle.on_shutdown(self.shutdown) if self.flipper and self.cat: lifecycle.on_block( functools.partial(seq_func, check_func=self.check_cdps)) elif self.flapper and self.vow: lifecycle.on_block( functools.partial(seq_func, check_func=self.check_flap)) elif self.flopper and self.vow: lifecycle.on_block( functools.partial(seq_func, check_func=self.check_flop)) else: # unusual corner case lifecycle.on_block(self.check_all_auctions) lifecycle.every(2, self.check_for_bids) def startup(self): self.approve() self.rebalance_dai() if self.flapper: self.logger.info( f"MKR balance is {self.mkr.balance_of(self.our_address)}") if not self.arguments.create_auctions: logging.info("Keeper will not create new auctions") def approve(self): self.strategy.approve() time.sleep(2) if self.dai_join: self.dai_join.approve(hope_directly(), self.vat.address) time.sleep(2) self.dai_join.dai().approve(self.dai_join.address).transact() def shutdown(self): with self.auctions_lock: del self.auctions self.exit_dai_on_shutdown() self.exit_collateral_on_shutdown() def exit_dai_on_shutdown(self): if not self.arguments.exit_dai_on_shutdown or not self.dai_join: return vat_balance = Wad(self.vat.dai(self.our_address)) if vat_balance > Wad(0): self.logger.info( f"Exiting {str(vat_balance)} Dai from the Vat before shutdown") assert self.dai_join.exit(self.our_address, vat_balance).transact() def exit_collateral_on_shutdown(self): if not self.arguments.exit_gem_on_shutdown or not self.gem_join: return vat_balance = self.vat.gem(self.ilk, self.our_address) if vat_balance > Wad(0): self.logger.info( f"Exiting {str(vat_balance)} {self.ilk.name} from the Vat before shutdown" ) assert self.gem_join.exit(self.our_address, vat_balance).transact() def check_cdps(self): started = datetime.now() ilk = self.vat.ilk(self.ilk.name) rate = ilk.rate dai_to_bid = self.vat.dai(self.our_address) # Look for unsafe CDPs and bite them urns = self.urn_history.get_urns() for urn in urns.values(): safe = urn.ink * ilk.spot >= urn.art * rate if not safe: if dai_to_bid == Rad(0): self.logger.warning( f"Skipping opportunity to bite urn {urn.address} " "because there is no Dai to bid") break if urn.ink < self.min_flip_lot: self.logger.info( f"Ignoring urn {urn.address.address} with ink={urn.ink} < " f"min_lot={self.min_flip_lot}") continue self._run_future(self.cat.bite(ilk, urn).transact_async()) self.logger.debug( f"Checked {len(urns)} urns in {(datetime.now()-started).seconds} seconds" ) # Cat.bite implicitly kicks off the flip auction; no further action needed. def check_flap(self): # Check if Vow has a surplus of Dai compared to bad debt joy = self.vat.dai(self.vow.address) awe = self.vat.sin(self.vow.address) # Check if Vow has Dai in excess if joy > awe: bump = self.vow.bump() hump = self.vow.hump() # Check if Vow has enough Dai surplus to start an auction and that we have enough mkr balance if (joy - awe) >= (bump + hump): if self.mkr.balance_of(self.our_address) == Wad(0): self.logger.warning( "Skipping opportunity to heal/flap because there is no MKR to bid" ) return woe = self.vow.woe() # Heal the system to bring Woe to 0 if woe > Rad(0): self.vow.heal(woe).transact() self.vow.flap().transact() def check_flop(self): # Check if Vow has a surplus of bad debt compared to Dai joy = self.vat.dai(self.vow.address) awe = self.vat.sin(self.vow.address) # Check if Vow has bad debt in excess excess_debt = joy < awe if not excess_debt: return woe = self.vow.woe() sin = self.vow.sin() sump = self.vow.sump() wait = self.vow.wait() # Check if Vow has enough bad debt to start an auction and that we have enough dai balance if woe + sin >= sump: # We need to bring Joy to 0 and Woe to at least sump if self.vat.dai(self.our_address) == Rad(0): self.logger.warning( "Skipping opportunity to kiss/flog/heal/flop because there is no Dai to bid" ) return # first use kiss() as it settled bad debt already in auctions and doesn't decrease woe ash = self.vow.ash() goodnight = min(ash, joy) if goodnight > Rad(0): self.vow.kiss(goodnight).transact() # Convert enough sin in woe to have woe >= sump + joy if woe < (sump + joy) and self.cat is not None: past_blocks = self.web3.eth.blockNumber - self.arguments.from_block for bite_event in self.cat.past_bites( past_blocks): # TODO: cache ? era = bite_event.era(self.web3) now = self.web3.eth.getBlock('latest')['timestamp'] sin = self.vow.sin_of(era) # If the bite hasn't already been flogged and has aged past the `wait` if sin > Rad(0) and era + wait <= now: self.vow.flog(era).transact() # flog() sin until woe is above sump + joy joy = self.vat.dai(self.vow.address) if self.vow.woe() - joy >= sump: break # use heal() for reconciling the remaining joy joy = self.vat.dai(self.vow.address) if Rad(0) < joy <= self.vow.woe(): self.vow.heal(joy).transact() # heal() changes joy and woe (the balance of surplus and debt) joy = self.vat.dai(self.vow.address) woe = self.vow.woe() if sump <= woe and joy == Rad(0): self.vow.flop().transact() def check_all_auctions(self): started = datetime.now() for id in range(1, self.strategy.kicks() + 1): with self.auctions_lock: if not self.check_auction(id): continue # Prevent growing the auctions collection beyond the configured size if len(self.auctions.auctions) < self.arguments.max_auctions: self.feed_model(id) else: logging.warning( f"Processing {len(self.auctions.auctions)} auctions; " f"ignoring auction {id} and beyond") break self.logger.debug( f"Checked {self.strategy.kicks()} auctions in {(datetime.now() - started).seconds} seconds" ) def check_for_bids(self): with self.auctions_lock: for id, auction in self.auctions.auctions.items(): self.handle_bid(id=id, auction=auction) # TODO if we will introduce multithreading here, proper locking should be introduced as well # locking should not happen on `auction.lock`, but on auction.id here. as sometimes we will # intend to lock on auction id but not create `Auction` object for it (as the auction is already finished # for example). def check_auction(self, id: int) -> bool: assert isinstance(id, int) if id in self.dead_auctions: return False # Read auction information input = self.strategy.get_input(id) auction_missing = (input.end == 0) auction_finished = (input.tic < input.era and input.tic != 0) or (input.end < input.era) if auction_missing: # Try to remove the auction so the model terminates and we stop tracking it. # If auction has already been removed, nothing happens. self.auctions.remove_auction(id) self.dead_auctions.add(id) return False # Check if the auction is finished. # If it is finished and we are the winner, `deal` the auction. # If it is finished and we aren't the winner, there is no point in carrying on with this auction. elif auction_finished: if input.guy == self.our_address: # Always using default gas price for `deal` self._run_future( self.strategy.deal(id).transact_async( gas_price=DefaultGasPrice())) # Upon winning a flip or flop auction, we may need to replenish Dai to the Vat. # Upon winning a flap auction, we may want to withdraw won Dai from the Vat. self.rebalance_dai() else: # Try to remove the auction so the model terminates and we stop tracking it. # If auction has already been removed, nothing happens. self.auctions.remove_auction(id) return False else: return True def feed_model(self, id: int): assert isinstance(id, int) auction = self.auctions.get_auction(id) input = self.strategy.get_input(id) # Feed the model with current state auction.feed_model(input) def handle_bid(self, id: int, auction: Auction): assert isinstance(id, int) assert isinstance(auction, Auction) output = auction.model_output() if output is not None: bid_price, bid_transact, cost = self.strategy.bid(id, output.price) # If we can't afford the bid, log a warning/error and back out. # By continuing, we'll burn through gas fees while the keeper pointlessly retries the bid. if cost is not None: if not self.check_bid_cost(cost): return if bid_price is not None and bid_transact is not None: # if no transaction in progress, send a new one transaction_in_progress = auction.transaction_in_progress() if transaction_in_progress is None: self.logger.info( f"Sending new bid @{output.price} (gas_price={output.gas_price})" ) auction.price = bid_price auction.gas_price = UpdatableGasPrice(output.gas_price) auction.register_transaction(bid_transact) self._run_future( bid_transact.transact_async( gas_price=auction.gas_price)) # if transaction in progress and gas price went up... elif output.gas_price and output.gas_price > auction.gas_price.gas_price: # ...replace the entire bid if the price has changed... if bid_price != auction.price: self.logger.info( f"Overriding pending bid with new bid @{output.price} (gas_price={output.gas_price})" ) auction.price = bid_price auction.gas_price = UpdatableGasPrice(output.gas_price) auction.register_transaction(bid_transact) self._run_future( bid_transact.transact_async( replace=transaction_in_progress, gas_price=auction.gas_price)) # ...or just replace gas_price if price stays the same else: self.logger.info( f"Overriding pending bid with new gas_price ({output.gas_price})" ) auction.gas_price.update_gas_price(output.gas_price) def check_bid_cost(self, cost: Rad) -> bool: assert isinstance(cost, Rad) # If this is an auction where we bid with Dai... if self.flipper or self.flopper: vat_dai = self.vat.dai(self.our_address) if cost > vat_dai: self.logger.debug( f"Bid cost {str(cost)} exceeds vat balance of {vat_dai}; " "bid will not be submitted") return False # If this is an auction where we bid with MKR... elif self.flapper: mkr_balance = self.mkr.balance_of(self.our_address) if cost > Rad(mkr_balance): self.logger.debug( f"Bid cost {str(cost)} exceeds MKR balance of {mkr_balance}; " "bid will not be submitted") return False return True def rebalance_dai(self): if self.vat_dai_target is None or not self.dai_join or ( not self.flipper and not self.flopper): return dai = self.dai_join.dai() token_balance = dai.balance_of(self.our_address) # Wad difference = Wad(self.vat.dai( self.our_address)) - self.vat_dai_target # Wad if difference < Wad(0): # Join tokens to the vat if token_balance >= difference * -1: self.logger.info( f"Joining {str(difference * -1)} Dai to the Vat") assert self.dai_join.join(self.our_address, difference * -1).transact() elif token_balance > Wad(0): self.logger.warning( f"Insufficient balance to maintain Dai target; joining {str(token_balance)} " "Dai to the Vat") assert self.dai_join.join(self.our_address, token_balance).transact() else: self.logger.warning( "No Dai is available to join to Vat; cannot maintain Dai target" ) elif difference > Wad(0): # Exit dai from the vat self.logger.info(f"Exiting {str(difference)} Dai from the Vat") assert self.dai_join.exit(self.our_address, difference).transact() self.logger.info( f"Dai token balance: {str(dai.balance_of(self.our_address))}, " f"Vat balance: {self.vat.dai(self.our_address)}") @staticmethod def _run_future(future): def worker(): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: asyncio.get_event_loop().run_until_complete(future) finally: loop.close() thread = threading.Thread(target=worker, daemon=True) thread.start()
class AuctionKeeper: logger = logging.getLogger() dead_after = 10 # Assume block reorgs cannot resurrect an auction id after this many blocks def __init__(self, args: list, **kwargs): parser = argparse.ArgumentParser(prog='auction-keeper') parser.add_argument( "--rpc-host", type=str, default="http://*****:*****@ {bid_price} {cost}") if cost is not None: if not self.check_bid_cost(cost): return if bid_price is not None and bid_transact is not None: # Ensure this auction has a gas strategy assigned (new_gas_strategy, fixed_gas_price_changed) = auction.determine_gas_strategy_for_bid( output, self.gas_price) # if no transaction in progress, send a new one transaction_in_progress = auction.transaction_in_progress() logging.info(f"should be bidding") # if transaction has not been submitted... if transaction_in_progress is None: self.logger.info( f"Sending new bid @{managed_price} (gas_price={output.gas_price})" ) auction.price = bid_price auction.gas_price = new_gas_strategy if new_gas_strategy else auction.gas_price auction.register_transaction(bid_transact) # ...submit a new transaction and wait the delay period (if so configured) self._run_future( bid_transact.transact_async(gas_price=auction.gas_price)) if self.arguments.bid_delay: logging.debug(f"Waiting {self.arguments.bid_delay}s") time.sleep(self.arguments.bid_delay) # if transaction in progress and the bid price changed... elif bid_price != auction.price: self.logger.info( f"Attempting to override pending bid with new bid @{managed_price} for auction {id}" ) auction.price = bid_price if new_gas_strategy: # gas strategy changed auction.gas_price = new_gas_strategy elif fixed_gas_price_changed: # gas price updated assert isinstance(auction.gas_price, UpdatableGasPrice) auction.gas_price.update_gas_price(output.gas_price) auction.register_transaction(bid_transact) # ...ask pymaker to replace the transaction self._run_future( bid_transact.transact_async( replace=transaction_in_progress, gas_price=auction.gas_price)) # if model has been providing a gas price, and only that changed... elif fixed_gas_price_changed: assert isinstance(auction.gas_price, UpdatableGasPrice) self.logger.info( f"Overriding pending bid with new gas_price ({output.gas_price}) for auction {id}" ) auction.gas_price.update_gas_price(output.gas_price) # if transaction in progress, bid price unchanged, but gas strategy changed... elif new_gas_strategy: self.logger.info( f"Changing gas strategy for pending bid @{managed_price} for auction {id}" ) auction.price = bid_price auction.gas_price = new_gas_strategy auction.register_transaction(bid_transact) # ...ask pymaker to replace the transaction self._run_future( bid_transact.transact_async( replace=transaction_in_progress, gas_price=auction.gas_price)) def check_bid_cost(self, cost: Rad) -> bool: assert isinstance(cost, Rad) # If this is an auction where we bid with Dai... if self.flipper or self.flopper: vat_dai = self.vat.dai(self.our_address) if cost > vat_dai: self.logger.info( f"Bid cost {str(cost)} exceeds vat balance of {vat_dai}; " "bid will not be submitted") return False # If this is an auction where we bid with MKR... elif self.flapper: mkr_balance = self.mkr.balance_of(self.our_address) if cost > Rad(mkr_balance): self.logger.debug( f"Bid cost {str(cost)} exceeds MKR balance of {mkr_balance}; " "bid will not be submitted") return False return True def rebalance_dai(self): logging.info( f"Checking if internal Dai balance needs to be rebalanced") if self.vat_dai_target is None or not self.dai_join or ( not self.flipper and not self.flopper): return dai = self.dai_join.dai() token_balance = dai.balance_of(self.our_address) # Wad difference = Wad(self.vat.dai( self.our_address)) - self.vat_dai_target # Wad if difference < Wad(0): # Join tokens to the vat if token_balance >= difference * -1: self.logger.info( f"Joining {str(difference * -1)} Dai to the Vat") assert self.dai_join.join( self.our_address, difference * -1).transact(gas_price=self.gas_price) elif token_balance > Wad(0): self.logger.warning( f"Insufficient balance to maintain Dai target; joining {str(token_balance)} " "Dai to the Vat") assert self.dai_join.join( self.our_address, token_balance).transact(gas_price=self.gas_price) else: self.logger.warning( "No Dai is available to join to Vat; cannot maintain Dai target" ) elif difference > Wad(0): # Exit dai from the vat self.logger.info(f"Exiting {str(difference)} Dai from the Vat") assert self.dai_join.exit( self.our_address, difference).transact(gas_price=self.gas_price) self.logger.info( f"Dai token balance: {str(dai.balance_of(self.our_address))}, " f"Vat balance: {self.vat.dai(self.our_address)}") @staticmethod def _run_future(future): def worker(): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: asyncio.get_event_loop().run_until_complete(future) finally: loop.close() thread = threading.Thread(target=worker, daemon=True) thread.start()
web3 = Web3( HTTPProvider(endpoint_uri=os.environ["ETH_RPC_URL"], request_kwargs={"timeout": 240})) vulcanize_endpoint = sys.argv[1] vulcanize_key = sys.argv[2] mcd = DssDeployment.from_node(web3) collateral_type = sys.argv[3] if len(sys.argv) > 3 else "ETH-A" ilk = mcd.collaterals[collateral_type].ilk # on mainnet, use 8928152 for ETH-A/BAT-A, 9989448 for WBTC-A, 10350821 for ZRX-A/KNC-A from_block = int(sys.argv[4]) if len(sys.argv) > 4 else 8928152 # Retrieve data from chain started = datetime.now() print(f"Connecting to {sys.argv[1]}...") uh = UrnHistory(web3, mcd, ilk, from_block, None, None) urns_logs = uh.get_urns() elapsed: timedelta = datetime.now() - started print( f"Found {len(urns_logs)} urns from block {from_block} in {elapsed.seconds} seconds" ) # Retrieve data from Vulcanize started = datetime.now() print(f"Connecting to {vulcanize_endpoint}...") uh = UrnHistory(web3, mcd, ilk, None, vulcanize_endpoint, vulcanize_key) urns_vdb = uh.get_urns() elapsed: timedelta = datetime.now() - started print( f"Found {len(urns_vdb)} urns from Vulcanize in {elapsed.seconds} seconds") # Reconcile the data
class AuctionKeeper: logger = logging.getLogger() dead_after = 10 # Assume block reorgs cannot resurrect an auction id after this many blocks def __init__(self, args: list, **kwargs): parser = argparse.ArgumentParser(prog='auction-keeper') parser.add_argument("--rpc-host", type=str, default="http://*****:*****@{output.price} for auction {id}") auction.price = bid_price auction.gas_price = new_gas_strategy if new_gas_strategy else auction.gas_price auction.register_transaction(bid_transact) # ...submit a new transaction and wait the delay period (if so configured) self._run_future(bid_transact.transact_async(gas_price=auction.gas_price)) if self.arguments.bid_delay: logging.debug(f"Waiting {self.arguments.bid_delay}s") time.sleep(self.arguments.bid_delay) # if transaction in progress and the bid price changed... elif auction.price and bid_price != auction.price: self.logger.info(f"Attempting to override pending bid with new bid @{output.price} for auction {id}") auction.price = bid_price if new_gas_strategy: # gas strategy changed auction.gas_price = new_gas_strategy elif fixed_gas_price_changed: # gas price updated assert isinstance(auction.gas_price, UpdatableGasPrice) auction.gas_price.update_gas_price(output.gas_price) auction.register_transaction(bid_transact) # ...ask pymaker to replace the transaction self._run_future(bid_transact.transact_async(replace=transaction_in_progress, gas_price=auction.gas_price)) # if model has been providing a gas price, and only that changed... elif fixed_gas_price_changed: assert isinstance(auction.gas_price, UpdatableGasPrice) self.logger.info(f"Overriding pending bid with new gas_price ({output.gas_price}) for auction {id}") auction.gas_price.update_gas_price(output.gas_price) # if transaction in progress, bid price unchanged, but gas strategy changed... elif new_gas_strategy: self.logger.info(f"Changing gas strategy for pending bid @{output.price} for auction {id}") auction.price = bid_price auction.gas_price = new_gas_strategy auction.register_transaction(bid_transact) # ...ask pymaker to replace the transaction self._run_future(bid_transact.transact_async(replace=transaction_in_progress, gas_price=auction.gas_price)) def check_bid_cost(self, id: int, cost: Rad, reservoir: Reservoir, already_rebalanced=False) -> bool: assert isinstance(id, int) assert isinstance(cost, Rad) # If this is an auction where we bid with Dai... if self.flipper or self.flopper: if not reservoir.check_bid_cost(id, cost): if not already_rebalanced: # Try to synchronously join Dai the Vat if self.is_joining_dai: self.logger.info(f"Bid cost {str(cost)} exceeds reservoir level of {reservoir.level}; " "waiting for Dai to rebalance") return False else: rebalanced = self.rebalance_dai() if rebalanced and rebalanced > Wad(0): reservoir.refill(Rad(rebalanced)) return self.check_bid_cost(id, cost, reservoir, already_rebalanced=True) self.logger.info(f"Bid cost {str(cost)} exceeds reservoir level of {reservoir.level}; " "bid will not be submitted") return False # If this is an auction where we bid with MKR... elif self.flapper: mkr_balance = self.mkr.balance_of(self.our_address) if cost > Rad(mkr_balance): self.logger.debug(f"Bid cost {str(cost)} exceeds reservoir level of {reservoir.level}; " "bid will not be submitted") return False return True def rebalance_dai(self) -> Optional[Wad]: # Returns amount joined (positive) or exited (negative) as a result of rebalancing towards vat_dai_target if self.arguments.vat_dai_target is None: return None logging.info(f"Checking if internal Dai balance needs to be rebalanced") dai = self.dai_join.dai() token_balance = dai.balance_of(self.our_address) # Wad # Prevent spending gas on small rebalances dust = Wad(self.mcd.vat.ilk(self.ilk.name).dust) if self.ilk else Wad.from_number(20) dai_to_join = Wad(0) dai_to_exit = Wad(0) try: if self.arguments.vat_dai_target.upper() == "ALL": dai_to_join = token_balance else: dai_target = Wad.from_number(float(self.arguments.vat_dai_target)) if dai_target < dust: self.logger.warning(f"Dust cutoff of {dust} exceeds Dai target {dai_target}; " "please adjust configuration accordingly") vat_balance = Wad(self.vat.dai(self.our_address)) if vat_balance < dai_target: dai_to_join = dai_target - vat_balance elif vat_balance > dai_target: dai_to_exit = vat_balance - dai_target except ValueError: raise ValueError("Unsupported --vat-dai-target") if dai_to_join >= dust: # Join tokens to the vat if token_balance >= dai_to_join: self.logger.info(f"Joining {str(dai_to_join)} Dai to the Vat") return self.join_dai(dai_to_join) elif token_balance > Wad(0): self.logger.warning(f"Insufficient balance to maintain Dai target; joining {str(token_balance)} " "Dai to the Vat") return self.join_dai(token_balance) else: self.logger.warning("Insufficient Dai is available to join to Vat; cannot maintain Dai target") return Wad(0) elif dai_to_exit > dust: # Exit dai from the vat self.logger.info(f"Exiting {str(dai_to_exit)} Dai from the Vat") assert self.dai_join.exit(self.our_address, dai_to_exit).transact(gas_price=self.gas_price) return dai_to_exit * -1 self.logger.info(f"Dai token balance: {str(dai.balance_of(self.our_address))}, " f"Vat balance: {self.vat.dai(self.our_address)}") def join_dai(self, amount: Wad): assert isinstance(amount, Wad) assert not self.is_joining_dai try: self.is_joining_dai = True assert self.dai_join.join(self.our_address, amount).transact(gas_price=self.gas_price) finally: self.is_joining_dai = False return amount def exit_gem(self): if not self.collateral: return token = Token(self.collateral.ilk.name.split('-')[0], self.collateral.gem.address, self.collateral.adapter.dec()) vat_balance = self.vat.gem(self.ilk, self.our_address) if vat_balance > token.min_amount: self.logger.info(f"Exiting {str(vat_balance)} {self.ilk.name} from the Vat") assert self.gem_join.exit(self.our_address, token.unnormalize_amount(vat_balance)).transact(gas_price=self.gas_price) @staticmethod def _run_future(future): def worker(): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: asyncio.get_event_loop().run_until_complete(future) finally: loop.close() thread = threading.Thread(target=worker, daemon=True) thread.start()