Skip to content

Model smart contract function calls as commands #3268

@hackaugusto

Description

@hackaugusto

Abstract

To support Overwrite in-flight transactions in-flight transactions #2801, Transaction prioritization #2802, and Transaction batching #4063 it's necessary an object to represent transactions, much like the ContractSend* state changes, but that can be manipulated by a transaction manager.

Additionally, the current implementation of the proxies are cumbersome, as of 1466689 the token network proxy has 1238 lines, and it's increasing.

Motivation

This is a proposal to enable the issues and simplify the current code base, by representing each possible transaction as a command instance.

Specification

I have a non working prototype bellow, which should be enough to understand the design I have in mind.

from abc import ABC, abstractmethod


class TXIdentifier(typing.Namedtuple):
    txhash: TXHash
    # Nonce if local signing is used, an unique value per rpcclient otherwise
    # (necessary to increase the gasprice of a transaction)
    cookie: Cookie


class Transaction(abc.ABC):
    def __init__(
            self,
            rpclient: RPCClient,
            contract: Address,
            function_selector: FunctionSelector,
            data: bytes
    ):
        self.rpcclient = rpclient
        self.contract = contract
        self.function_selector = function_selector
        self.data = data

    @abstractmethod
    def approximated_startgas(self):
        """ Estimated startgas based on local tests.

        This could be done by using py-evm, or by approximation formulas
        based on the smart contract tests.
        """
        pass

    @abstractmethod
    def validate_preconditions(self, blockhash: BlockHash):
        """ Raises if sending this transaction will result in an execution
        failure at the given `blockhash`.

        raises:
            UnrecoverableError: If the required preconditions have never been
                met to successfully execute the transaction.
            RecoverableError: If the required preconditions have been
                invalidated and sending the transaction would fail.
        """
        pass

    @absctractmehod
    def validate_after_failure(self, blockspec: BlockSpec):
        """ Raises an exception describing the reason for the transaction
        failure.

        raises:
            RecoverableError: If this transaction raced and lost with another
                transaction, which invalidated the required preconditions after the
                given block.
        """
        pass

    def send(
            self,
            blockhash: BlockHash,
            gasprice: GasPrice,
            nonce: Nonce = None,
    ):
        self.validate_preconditions(blockhash)
        startgas = self.rpcclient.gasestimate()
        if startgas is not None:
            self.gasprice = gasprice
            self.txidentifier: TXIdentifier = self.rpclient.send(startgas, gasprice)
        else:
            self.validate_after_failure('latest')
            if self.client.balance() < self.approximated_startgas():
                raise UnrecoverableError('insufficient balance')
            raise UnrecoverableError('unexpected error or insufficient balance')

    def increase_gasprice(self, gasprice: GasPrice):
        """ Try to resend this this transaction with a higher gasprice to
        increase the likelyhood of it being mined.
        """
        assert self.txidentifier is not None
        assert self.gasprice and self.gasprice < gasprice
        self.gasprice = gasprice
        self.rpclient.send(startgas, gasprice, txidentifier=self.txidentifier)

    def replace(self, blockhash: BlockHash, other: Transaction) -> TXHash:
        """ Try to replace `other` with this transaction.

        `other` maybe a transaction for the exact same
        """
        assert other.gasprice
        self.send(
            blockhash=blockhash,
            gasprice=other.gasprice + 1,
            nonce=other.nonce,
        )


class TransactionManager:
    def __init__(self, blockhash: BlockHash):
        self.heap = list()
        self.blockhash
        self.gasprice = self.estimate_gasprice()

    def block_callback(self):
        """ When a new block is mined, upgrade local state and check if a
        transaction has been mined.

        This should be installed as a callback to the AlarmTask.
        """
        self.update()  # updated confirmed blockhash and gasprice estimate
        self.poll_transactions()  # check if a pending transaction has been mined

    def poll_transactions(self):
        if self.pending:
            result = self.rpcclient.poll(self.pending)

            if isinstance(result, RPCFailure):
                # This needs additional handling, since the RPCFailure could be that we
                # tried to replace a transaction, but it failed (the previous transaction got
                # mined first).
                raise result.exception

            if isinstance(result, TransactionFailure):
                self.pending.validate_after_failure()
                raise UnrecoverableError('unexpected error')

            if isinstance(result, Success):
                heap.pop(self.heap, self.pending)
                self.maybe_send_next()

            elif isinstance(result, Pending) and self.has_to_increase_gas(self.pending):
                # gasprice updated by demand on new blocks
                self.pending.increase_gasprice(self.gasprice)

    def add_transaction(self, transaction: Transaction):
        heap.push(self.heap, transaction)

        if self.pending is None:
            self.pending = transaction
            transaction.send(self.blockhash, self.gasprice)
        elif higher_priority(transaction, self.pending):
            self.pending = transaction.replace(self.blockhash, self.pending)

    def maybe_send_next(self):
        if self.pending is None:
            self.pending = heap.peek(self.heap)
            self.pending.send(self.blockhash, self.gasprice)


class SetTotalDepositTransaction(Transaction):
    def __init__(
            self,
            channel_identifier: ChannelID,
            total_deposit: TokenAmount,
            our_address: Address,
            partner: Address,
    ):
        if not isinstance(total_deposit, int):
            raise ValueError('total_deposit needs to be an integral number.')

        self.channel_identifier = channel_identifier
        self.total_deposit = total_deposit
        self.our_address = our_address
        self.partner = partner

    def validate_preconditions(self, blockhash: BlockHash):
        onchannel_identifier = self.proxy.get_channel_identifier()

        if onchain_channel_identifier != self.channel_identifier:
            raise ChannelOutdatedError(
                'Current channel identifier is outdated. '
                f'current={self.channel_identifier}, '
                f'new={onchain_channel_identifier}',
            )

        previous_total_deposit = self.proxy.participant_deposit()

        if total_deposit < previous_total_deposit:
            msg = (
                f'Current total deposit ({previous_total_deposit}) is already larger '
                f'than the requested total deposit amount ({total_deposit})'
            )
            log.info('setTotalDeposit failed', reason=msg, **log_details)
            raise DepositMismatch(msg)

        current_balance = self.proxy.balance_of(self.our_address)
        if current_balance < amount_to_deposit:
            msg = (
                f'new_total_deposit - previous_total_deposit =  {amount_to_deposit} can not '
                f'be larger than the available balance {current_balance}, '
                f'for token at address {pex(token_address)}'
            )
            log.info('setTotalDeposit failed', reason=msg, **log_details)
            raise DepositMismatch(msg)

    def validate_after_failure(self, blockspec: BlockSpec):
        if token.allowance(self.node_address, self.address) < amount_to_deposit:
            log_msg = (
                'The allowance is insufficient, '
                'check concurrent deposits for the same token network '
                'but different proxies.'
            )
        elif token.balance_of(self.node_address) < amount_to_deposit:
            log_msg = "The address doesn't have enough funds"
        elif latest_deposit < total_deposit:
            log_msg = 'The tokens were not transferred'
        else:
            log_msg = 'unknown'

        log.critical('setTotalDeposit failed', reason=log_msg, **log_details)

        if channel_state in (ChannelState.NONEXISTENT, ChannelState.REMOVED):
            raise RaidenUnrecoverableError(
                f'Channel between participant {participant1} '
                f'and {participant2} does not exist',
            )
        elif channel_state == ChannelState.SETTLED:
            raise RaidenUnrecoverableError(
                'Deposit is not possible due to channel being settled',
            )
        elif channel_state == ChannelState.CLOSED:
            raise RaidenRecoverableError(
                'Channel is already closed',
            )
        elif participant_details.our_details.deposit < deposit_amount:
            raise RaidenUnrecoverableError(
                'Deposit amount did not increase after deposit transaction',
            )

Pros

  • The design helps to think about error handling, by following a single pattern like the one described here
  • Improved polling, since the poll is done on the alarm task callback and not on a tight loop with an arbitrary timeout.
  • Allows the for transaction reordering and resending

Cons

While I was writing the above mock code, I realized this design will work only for one transaction at the time, so the deposit above has to be done in a single transaction, for the approve and setTotalDeposit.

Alternatively the design could be extended to allow for ordering of transactions.

Backwards Compatibility

This does not change any wire or database data. However, as state in cons it may require raiden-network/raiden-contracts#400 to be finalized, which will required upgrades to the smart contracts.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions