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.
Abstract
To support
Overwrite in-flight transactions in-flight transactions#2801,Transaction prioritization#2802, andTransaction batching#4063 it's necessary an object to represent transactions, much like theContractSend*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.
Pros
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.