class ArbitrageKeeper: """Keeper to arbitrage on OasisDEX, `join`, `exit`, `boom` and `bust`.""" logger = logging.getLogger('arbitrage-keeper') def __init__(self, args, **kwargs): parser = argparse.ArgumentParser("arbitrage-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("--tub-address", type=str, required=True, help="Ethereum address of the Tub contract") parser.add_argument("--tap-address", type=str, required=True, help="Ethereum address of the Tap contract") parser.add_argument( "--exchange-address", type=str, help="Ethereum address of the 0x Exchange contract") parser.add_argument("--oasis-address", type=str, required=True, help="Ethereum address of the OasisDEX contract") parser.add_argument( "--oasis-support-address", type=str, required=False, help="Ethereum address of the OasisDEX support contract") parser.add_argument("--relayer-api-server", type=str, help="Address of the 0x Relayer API") parser.add_argument( "--relayer-per-page", type=int, default=100, help= "Number of orders to fetch per one page from the 0x Relayer API (default: 100)" ) parser.add_argument( "--tx-manager", type=str, help= "Ethereum address of the TxManager contract to use for multi-step arbitrage" ) parser.add_argument("--gas-price", type=int, default=0, help="Gas price in Wei (default: node default)") parser.add_argument( "--base-token", type=str, required=True, help="The token all arbitrage sequences will start and end with") parser.add_argument( "--min-profit", type=float, required=True, help="Minimum profit (in base token) from one arbitrage operation") parser.add_argument( "--max-engagement", type=float, required=True, help="Maximum engagement (in base token) in one arbitrage operation" ) parser.add_argument( "--max-errors", type=int, default=100, help= "Maximum number of allowed errors before the keeper terminates (default: 100)" ) parser.add_argument("--debug", dest='debug', action='store_true', help="Enable debug output") self.arguments = parser.parse_args(args) self.web3 = kwargs['web3'] if 'web3' in kwargs else Web3( HTTPProvider( endpoint_uri= f"http://{self.arguments.rpc_host}:{self.arguments.rpc_port}", 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) self.tub = Tub(web3=self.web3, address=Address(self.arguments.tub_address)) self.tap = Tap(web3=self.web3, address=Address(self.arguments.tap_address)) self.gem = ERC20Token(web3=self.web3, address=self.tub.gem()) self.sai = ERC20Token(web3=self.web3, address=self.tub.sai()) self.skr = ERC20Token(web3=self.web3, address=self.tub.skr()) self.zrx_exchange = ZrxExchange(web3=self.web3, address=Address(self.arguments.exchange_address)) \ if self.arguments.exchange_address is not None else None self.zrx_relayer_api = ZrxRelayerApi(exchange=self.zrx_exchange, api_server=self.arguments.relayer_api_server) \ if self.arguments.relayer_api_server is not None else None self.otc = MatchingMarket( web3=self.web3, address=Address(self.arguments.oasis_address), support_address=Address(self.arguments.oasis_support_address) if self.arguments.oasis_support_address is not None else None) self.base_token = ERC20Token(web3=self.web3, address=Address( self.arguments.base_token)) self.min_profit = Wad.from_number(self.arguments.min_profit) self.max_engagement = Wad.from_number(self.arguments.max_engagement) self.max_errors = self.arguments.max_errors self.errors = 0 if self.arguments.tx_manager: self.tx_manager = TxManager(web3=self.web3, address=Address( self.arguments.tx_manager)) if self.tx_manager.owner() != self.our_address: raise Exception( f"The TxManager has to be owned by the address the keeper is operating from." ) else: self.tx_manager = None logging.basicConfig( format='%(asctime)-15s %(levelname)-8s %(message)s', level=(logging.DEBUG if self.arguments.debug else logging.INFO)) def main(self): with Lifecycle(self.web3) as lifecycle: self.lifecycle = lifecycle lifecycle.on_startup(self.startup) lifecycle.on_block(self.process_block) def startup(self): self.approve() def approve(self): """Approve all components that need to access our balances""" approval_method = via_tx_manager(self.tx_manager, gas_price=self.gas_price()) if self.tx_manager \ else directly(gas_price=self.gas_price()) self.tub.approve(approval_method) self.tap.approve(approval_method) self.otc.approve([self.gem, self.sai, self.skr], approval_method) if self.zrx_exchange: self.zrx_exchange.approve([self.gem, self.sai], approval_method) if self.tx_manager: self.tx_manager.approve([self.gem, self.sai, self.skr], directly(gas_price=self.gas_price())) def token_name(self, address: Address) -> str: if address == self.sai.address: return "DAI" elif address == self.gem.address: return "WETH" elif address == self.skr.address: return "PETH" else: return str(address) def tub_conversions(self) -> List[Conversion]: return [ TubJoinConversion(self.tub), TubExitConversion(self.tub), TubBoomConversion(self.tub, self.tap), TubBustConversion(self.tub, self.tap) ] def otc_orders(self, tokens): orders = [] for token1 in tokens: for token2 in tokens: if token1 != token2: orders = orders + self.otc.get_orders(token1, token2) return orders def otc_conversions(self, tokens) -> List[Conversion]: return list( map(lambda order: OasisTakeConversion(self.otc, order), self.otc_orders(tokens))) def zrx_orders(self, tokens): if self.zrx_exchange is None or self.zrx_relayer_api is None: return [] orders = [] for token1 in tokens: for token2 in tokens: if token1 != token2: orders = orders + self.zrx_relayer_api.get_orders( token1, token2) return list( filter(lambda order: order.expiration <= time.time(), orders)) def zrx_conversions(self, tokens) -> List[Conversion]: return list( map(lambda order: ZrxFillOrderConversion(self.zrx_exchange, order), self.zrx_orders(tokens))) def all_conversions(self): return self.tub_conversions() + \ self.otc_conversions([self.sai.address, self.skr.address, self.gem.address]) + \ self.zrx_conversions([self.sai.address, self.gem.address]) def process_block(self): """Callback called on each new block. If too many errors, terminate the keeper to minimize potential damage.""" if self.errors >= self.max_errors: self.lifecycle.terminate() else: self.execute_best_opportunity_available() def execute_best_opportunity_available(self): """Find the best arbitrage opportunity present and execute it.""" opportunity = self.best_opportunity(self.profitable_opportunities()) if opportunity: self.print_opportunity(opportunity) self.execute_opportunity(opportunity) def profitable_opportunities(self): """Identify all profitable arbitrage opportunities within given limits.""" entry_amount = Wad.min(self.base_token.balance_of(self.our_address), self.max_engagement) opportunity_finder = OpportunityFinder( conversions=self.all_conversions()) opportunities = opportunity_finder.find_opportunities( self.base_token.address, entry_amount) opportunities = filter( lambda op: op.total_rate() > Ray.from_number(1.000001), opportunities) opportunities = filter( lambda op: op.profit(self.base_token.address) > self.min_profit, opportunities) opportunities = sorted( opportunities, key=lambda op: op.profit(self.base_token.address), reverse=True) return opportunities def best_opportunity(self, opportunities: List[Sequence]): """Pick the best opportunity, or return None if no profitable opportunities.""" return opportunities[0] if len(opportunities) > 0 else None def print_opportunity(self, opportunity: Sequence): """Print the details of the opportunity.""" self.logger.info( f"Opportunity with id={opportunity.id()}," f" profit={opportunity.profit(self.base_token.address)} {self.token_name(self.base_token.address)}" ) for index, conversion in enumerate(opportunity.steps, start=1): self.logger.info( f"Step {index}/{len(opportunity.steps)}: {conversion.name()}" f" (from {conversion.source_amount} {self.token_name(conversion.source_token)}" f" to {conversion.target_amount} {self.token_name(conversion.target_token)})" ) def execute_opportunity(self, opportunity: Sequence): """Execute the opportunity either in one Ethereum transaction or step-by-step. Depending on whether `tx_manager` is available.""" if self.tx_manager: self.execute_opportunity_in_one_transaction(opportunity) else: self.execute_opportunity_step_by_step(opportunity) def execute_opportunity_step_by_step(self, opportunity: Sequence): """Execute the opportunity step-by-step.""" def incoming_transfer(our_address: Address): return lambda transfer: transfer.to_address == our_address def outgoing_transfer(our_address: Address): return lambda transfer: transfer.from_address == our_address all_transfers = [] for step in opportunity.steps: receipt = step.transact().transact(gas_price=self.gas_price()) if receipt: all_transfers += receipt.transfers outgoing = TransferFormatter().format( filter(outgoing_transfer(self.our_address), receipt.transfers), self.token_name) incoming = TransferFormatter().format( filter(incoming_transfer(self.our_address), receipt.transfers), self.token_name) self.logger.info(f"Exchanged {outgoing} to {incoming}") else: self.errors += 1 return self.logger.info( f"The profit we made is {TransferFormatter().format_net(all_transfers, self.our_address, self.token_name)}" ) def execute_opportunity_in_one_transaction(self, opportunity: Sequence): """Execute the opportunity in one transaction, using the `tx_manager`.""" tokens = [self.sai.address, self.skr.address, self.gem.address] invocations = list( map(lambda step: step.transact().invocation(), opportunity.steps)) receipt = self.tx_manager.execute( tokens, invocations).transact(gas_price=self.gas_price()) if receipt: self.logger.info( f"The profit we made is {TransferFormatter().format_net(receipt.transfers, self.our_address, self.token_name)}" ) else: self.errors += 1 def gas_price(self): if self.arguments.gas_price > 0: return FixedGasPrice(self.arguments.gas_price) else: return DefaultGasPrice()
class CdpKeeper: """Keeper to actively manage open CDPs.""" logger = logging.getLogger('cdp-keeper') def __init__(self, args: list, **kwargs): parser = argparse.ArgumentParser(prog='cdp-keeper') parser.add_argument("--rpc-host", help="JSON-RPC host (default: `localhost')", default="localhost", type=str) parser.add_argument("--rpc-port", help="JSON-RPC port (default: `8545')", default=8545, type=int) parser.add_argument("--rpc-timeout", help="JSON-RPC timeout (in seconds, default: 10)", default=10, type=int) parser.add_argument( "--eth-from", help="Ethereum account from which to send transactions", required=True, type=str) parser.add_argument("--tub-address", help="Ethereum address of the Tub contract", required=True, type=str) parser.add_argument( "--min-margin", help= "Margin between the liquidation ratio and the top-up threshold", type=float, required=True) parser.add_argument( "--top-up-margin", help="Margin between the liquidation ratio and the top-up target", type=float, required=True) parser.add_argument("--max-sai", type=float, required=True) parser.add_argument("--avg-sai", type=float, required=True) parser.add_argument("--gas-price", help="Gas price in Wei (default: node default)", default=0, type=int) parser.add_argument("--debug", help="Enable debug output", dest='debug', action='store_true') self.arguments = parser.parse_args(args) self.web3 = kwargs['web3'] if 'web3' in kwargs else Web3( HTTPProvider( endpoint_uri= f"http://{self.arguments.rpc_host}:{self.arguments.rpc_port}", request_kwargs={"timeout": self.arguments.rpc_timeout})) self.web3.eth.defaultAccount = self.arguments.eth_from self.our_address = Address(self.arguments.eth_from) self.tub = Tub(web3=self.web3, address=Address(self.arguments.tub_address)) self.sai = ERC20Token(web3=self.web3, address=self.tub.sai()) self.liquidation_ratio = self.tub.mat() self.minimum_ratio = self.liquidation_ratio + Ray.from_number( self.arguments.min_margin) self.target_ratio = self.liquidation_ratio + Ray.from_number( self.arguments.top_up_margin) self.max_sai = Wad.from_number(self.arguments.max_sai) self.avg_sai = Wad.from_number(self.arguments.avg_sai) logging.basicConfig( format='%(asctime)-15s %(levelname)-8s %(message)s', level=(logging.DEBUG if self.arguments.debug else logging.INFO)) def main(self): with Lifecycle(self.web3) as lifecycle: lifecycle.on_startup(self.startup) lifecycle.on_block(self.check_all_cups) def startup(self): self.approve() def approve(self): self.tub.approve(directly(gas_price=self.gas_price())) def check_all_cups(self): for cup in self.our_cups(): self.check_cup(cup.cup_id) def check_cup(self, cup_id: int): assert (isinstance(cup_id, int)) # If cup is undercollateralized and the amount of SAI we are holding is more than `--max-sai` # then we wipe some debt first so our balance reaches `--avg-sai`. Bear in mind that it is # possible that we pay all out debt this way and our SAI balance will still be higher # than `--max-sai`. if self.is_undercollateralized(cup_id) and self.sai.balance_of( self.our_address) > self.max_sai: amount_of_sai_to_wipe = self.calculate_sai_wipe() if amount_of_sai_to_wipe > Wad(0): self.tub.wipe( cup_id, amount_of_sai_to_wipe).transact(gas_price=self.gas_price()) # If cup is still undercollateralized, calculate the amount of SKR needed to top it up so # the collateralization level reaches `--top-up-margin`. If we have enough ETH, exchange # in to SKR and then top-up the cup. if self.is_undercollateralized(cup_id): top_up_amount = self.calculate_skr_top_up(cup_id) if top_up_amount <= eth_balance(self.web3, self.our_address): # TODO we do not always join with the same amount as the one we lock! self.tub.join(top_up_amount).transact( gas_price=self.gas_price()) self.tub.lock( cup_id, top_up_amount).transact(gas_price=self.gas_price()) else: self.logger.info( f"Cannot top-up as our balance is less than {top_up_amount} ETH." ) def our_cups(self): for cup_id in range(1, self.tub.cupi() + 1): cup = self.tub.cups(cup_id) if cup.lad == self.our_address: yield cup def is_undercollateralized(self, cup_id) -> bool: pro = self.tub.ink(cup_id) * self.tub.tag() tab = self.tub.tab(cup_id) if tab > Wad(0): current_ratio = Ray(pro / tab) # Prints the Current CDP Ratio and the Minimum Ratio specified under --min-margin print(f'Current Ratio {current_ratio}') print(f'Minimum Ratio {self.minimum_ratio}') return current_ratio < self.minimum_ratio else: return False def calculate_sai_wipe(self) -> Wad: """Calculates the amount of SAI that can be wiped. Calculates the amount of SAI than can be wiped in order to bring the SAI holdings to `--avg-sai`. """ return Wad.max( self.sai.balance_of(self.our_address) - self.avg_sai, Wad(0)) def calculate_skr_top_up(self, cup_id) -> Wad: """Calculates the required top-up in SKR. Calculates the required top-up in SKR in order to bring the collateralization level of the cup to `--target-ratio`. """ pro = self.tub.ink(cup_id) * self.tub.tag() tab = self.tub.tab(cup_id) if tab > Wad(0): current_ratio = Ray(pro / tab) return Wad.max( tab * (Wad(self.target_ratio - current_ratio) / Wad.from_number(self.tub.tag())), Wad(0)) else: return Wad(0) def gas_price(self): if self.arguments.gas_price > 0: return FixedGasPrice(self.arguments.gas_price) else: return DefaultGasPrice()