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()
예제 #2
0
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()