shutter-network / encrypting-rpc-server

An Ethereum JSON RPC server for Shutterized Gnosis Chain that encrypts transactions
6 stars 2 forks source link

Decide how to handle nonce reusage #12

Closed fredo closed 2 months ago

fredo commented 4 months ago

Currently, the RPC server rejects sending the same transaction hash again. The reason for it is, that a traditional sendRawTransaction will just gossip the same tx through the network which doesn't have any effect.

In Shutter's case the transaction would be sequenced again, which will cost the relayer tx fees but as mentioned doesn't have any effect.

As a consequence the rpc server needs to have some opinion about whether to resend the transaction or not. This is specifically interesting when the user sends a transaction with a nonce which is currently queued but not yet executed.

Potential reasons why this could be the case:

1) The wallet retries to send transactions (this can be seen with metamask and frame). Note that the tx hash could change by the wallet automatically adjusting the gas price 2) A speed up transaction 3) A cancellation transaction 4) A normal user transaction if the first transaction became invalid between inclusion and execution

1)

In this case, the rpc should not send an inclusion transaction

2)

I think in this case it is fine to requeue the transaction but it is ambiguous to 1)

3)

The user's intention is to prioritize this transaction over the queued one. So one option could be to send it to the public mempool instead trying to frontrun the queued transaction. the typical cancel transaction pattern is sending 0 ETH to itself.

4)

Queued transactions can become invalid while they are waiting for execution. One specific case where this is possible is that there is another transaction N-1 which is also queued before N. N-1 would reduce the ETH balance so that the N cannot pay for gas fees so it would become invalid. In this case the user eventually wants to resend a transaction with the same nonce N at a later point in time.

Proposed solution:

Cache implementation: A cache that stores each transaction when it arrives following the below steps:

1) If no cache entry (first time to process the transaction):

2) If a cache entry exists with same or higher gas price: the transaction is discarded, sendingBlock is updated to currentBlock. We update sendingBlock as it is used by the next queued transaction as prevBlock, in order to delay the next transaction accordingly, i.e. give "enough time" to the current transaction to be sequenced and confirmed.

3) If a cache entry exists with lower gas information: replace the entry with the new transaction information (sendingBlock = prevBlock + Y blocks) - Y is a delay factor that we would configure manually by looking at historical data. todo: differentiate between legacy txs and EIP-1559 ones.

4) If a cancellation tx arrives, the tx is sent to the mempool and the entry removed from the cache.

New Block Listener: For each new block, we send all the queued transactions with sendingBlock = new block to the sequencer and update sendingBlock for these transactions to the new block.

Purging the cache: We purge the cache entry if no transaction was queued for the address within the last Y blocks after the last sent transaction.

ylembachar commented 4 months ago

@fredo: Here is a first draft for the solution:

Cache implementation: A cache that stores each transaction when it arrives following the below steps:

1) If no cache entry (first time to process the transaction):

2) If a cache entry exists with same txHash, the transaction is discarded, sendingBlock is updated to currentBlock and purgeBlock to currentBlock + X. (We update sendingBlock as it is used by the next queued transaction as prevBlock, in order to delay the next transaction accordingly, i.e. give "enough time" to the current transaction to be sequenced and confirmed. purgeBlock is updated too since we received the same transaction twice so we should delay the purging according to the latest received transaction. We're basically keeping track of when we last received that transaction that is being sequenced.)

3) If a cache entry exists with the same transaction data but higher gas information: replace the entry with the new transaction information (sendingBlock = prevBlock + Y blocks) - Y is a delay factor that we would configure manually. todo: differentiate between legacy txs and EIP-1559 ones.

4) If a cancellation tx arrives, the tx is sent to the mempool and the entry removed from the cache

New Block Listener: For each new block, we send all the queued transactions with sendingBlock = new block to the sequencer, and we purge the ones with purgeBlock = new block.

Transaction Listener: Whenever a transaction is confirmed, we remove the corresponding entries from the cache.

Invalid transactions are removed from the cache after X blocks.

Before sending a queued transaction, we would perform the same checks on the validity of the transaction so that we don't include already confirmed or cancelled ones.

fredo commented 4 months ago

If a cache entry exists with same information (same address, same nonce, same gas), the transaction is discarded, sendingBlock is updated to currentBlock and purgeBlock to currentBlock + X.

Why are sendingBlock and purgeBlock updated in this case?

If a cache entry exists with different gas information: replace the entry with the new transaction information (sendingBlock = prevBlock + Y blocks, status=queued) - Y is a delay factor that we would configure manually.

Wouldn't it be more precise to replace the transaction only if the gas price is higher?

Why do we need the status field?

What is prevBlock?

For transactions that become invalid if there is no way to know that a transaction was invalidated, we could purge the cache from the transaction after X blocks.

Isn't that already implicit by and we purge the ones with purgeBlock = new block.

ylembachar commented 4 months ago

If a cache entry exists with same information (same address, same nonce, same gas), the transaction is discarded, sendingBlock is updated to currentBlock and purgeBlock to currentBlock + X.

Why are sendingBlock and purgeBlock updated in this case?

This is because sendingBlock is used by the next queued transaction as prevBlock, so we delay the it accordingly. purgeBlock is updated too since we received the same transaction so we should delay the purging accordingly.

If a cache entry exists with different gas information: replace the entry with the new transaction information (sendingBlock = prevBlock + Y blocks, status=queued) - Y is a delay factor that we would configure manually.

Wouldn't it be more precise to replace the transaction only if the gas price is higher?

Yes, it will be the case, only if it is higher.

Why do we need the status field?

To differentiate between the first transaction that doesn't need to be sent again and the queued transaction that does.

What is prevBlock?

This is the previous block from the previous transaction with the same details as mentioned above. The first block the initial transaction could be included in would be prevBlock + 2 since it is issued at prevBlock and there is at least one block where the inclusion transaction is included. (One possibility would be to delay the queued transaction by prevBlock + 2 to ensure that we have waited for the earliest block the previous transaction could be included.)

For transactions that become invalid if there is no way to know that a transaction was invalidated, we could purge the cache from the transaction after X blocks.

Isn't that already implicit by and we purge the ones with purgeBlock = new block.

Yes, this was only to explain how we handle the case for invalid transactions.