On-chain refunds + Off-chain accounting = Watch out!
any.sender is a high-throughput transaction relayer that is accessible via an API. The user can provide a relay transaction to the any.sender API, our relayer packs it into an Ethereum transaction and then gradually bumps the network fee until it is mined.
A relay transaction is defined as a signed message from the user to authenticate with the any.sender service and to inform the service on how to craft the Ethereum Transaction. It includes target contract to execute (to), what data to supply when executing it (data), the quantity of gas to supply (gasLimit) and the network identifier (chainId):
relayTransaction = [{to, data, gas, chainId}, sig],
where sig = sign(keccak256(abi.encode(to, data, gas, chainId))
any.sender is agonistic to the transaction’s payload and how the user authenticates with the target contract.
As a side note, user authentication at the smart contract level may involve modifying the target contract to support meta-transactions or for the user to adopt a wallet contract. A blog for another day.
Relay approaches. There are two methods by which any.sender will pack the user’s relay transaction into an Ethereum Transaction:
- CLASSIC. The user’s request is wrapped and sent via an intermediary InstantRefundRelay.sol contract.
- DIRECT. We take the user’s relay transaction and send it directly to the target contract.
Most relay services implement the CLASSIC approach. Figure 1 is an example of the intermediary relay contract. The execute function tracks the gas used, executes the user’s transaction and then automatically top-up the relayer’s signing key. This ensures the relayer always has a satisfactory balance when sending the next transaction and the relayer only needs enough funds to cover the maximum cost of a single transaction. However, it does incur a gas overhead per transaction (~30–20k gas).
any.sender moved away from the CLASSIC approach. We are the first relay service to implement the DIRECT approach. It is superior from the user’s perspective as there is no additional contract wrapping and there is no gas overhead. However, from a relay service perspective, it is pretty tricky to implement correctly as it relies on a new off-chain refund mechanism to monitor and top-up each relayer’s balance when necessary. How the DIRECT refund mechanism works is a blog for another day….
Just in time too. We discovered a slightly annoying problem with the CLASSIC approach which will be the focus of this blog post.
Problem discovered with the CLASSIC approach.
In a nutshell, an issue arises when we consider the EVM’s functionality for refunding gas and how it intertwines with the relayer’s off-chain accounting system for user balances.
Gas refund mechanism. The EVM was initially designed with an incentive mechanism to encourage good behaviour. For us, this involves how the executor of a transaction can be refunded some gas if they delete values from long-term storage if it is no longer useful. This is one of the reasons why you’ll see a difference in the gas used for most transactions (subtle refunds).
Like all things in Ethereum, mechanism-design is hard and the gas refund mechanism led to the emergence of GasTokens. A gas token lets the user buy storage when the network fees are cheap, and then delete storage when the network fees are high. Thanks to GasToken, the user can pay less for their transaction which can be up to a ~50% discount.
However — the EVM only processes gas refunds once the transaction is complete and not during execution. This is problematic for us as the relay contract attempts to track the gas used by the user. For example, if the user spends 500k gas but receive a ~250k gas refund (via GasToken), then the relayer contract will send the relayer 500k gas worth of ETH (instead of 250k).
This is problematic as the relay contract will send more funds than necessary when recouping the relayer the cost of this transaction.
Off-chain accounting. The user sends a relay transaction to the any.sender service with a specified gas limit. Our payment gateway attempts to lock a portion of the user’s balance:
locked_funds = expectedGasPrice * gasLimit,
where expectedGasPrice is a conservative estimate on the network fee.
If the funds are locked successfully, then it the relay transaction is passed to the relay back-end system. any.sender sends the transaction to the network, gradually bumps the fee, and eventually it is mined. After a number of block confirmations, our service fetches the true cost of the transaction, unlocks the funds and deducts the final cost from the user’s balance.
Gas refund + off-chain account = problem. Together, it is possible for the relay contract to send more funds than necessary to the relayer balance. The user can simply send several transactions with large gas refunds (up to ~50% of gas used in a transaction). With the off-chain accounting, the user’s balance will be deducted by the actual gas used in the transaction and not the funds sent to the relayer.
Thus, the user will still have a positive balance and they can send further transactions to the service. If abused, the user can attempt to send all funds from the relay contract to a single relay key and as a result it disables the refund mechanism for the other relay keys. The cost of the drain attack is essentially double the funds in the relay contract and the total balance of the other relayers.
This issue does not arise if the relayer relies on on-chain accounting for the user’s balance. The user’s gas refunds are ignored and they are simply over-charged. It is beneficial for the relayer as their revenue is increased as it cost less to send the relayer’s transaction as expected. But on-chain accounting incurs a significant gas overhead for every transaction.
Mitigating the over refunding issue.
There are two approaches to mitigate the issue.
Update the relay contract. The wrapped transaction can include a MAXBALANCE parameter that specifies the maximum balance of funds a relayer signing key should hold. The relay contract can simply check the relayer’s current balance and compare it with MAXBALANCE. If it is larger, then the contract will not process the refund.
Deposit transactions. Our relayers can self-inspect their balance and deposit back to the relay contract if it exceeds the MAXBALANCE threshold.
Clearly, updating the relay contract is the easiest approach to mitigate the issue, but it does incur a gas overhead per relay transaction to inspect the relayer’s balance (~1k gas or so). The deposit transaction approach sounds trivial, but there is a financial cost to perform a deposit back to the relay contract and this can be abused to hurt the relayer’s revenue.
There is a third approach — just disable CLASSIC. This is the option we chosen.
Compared to DIRECT transactions, there does not appear to be a meaningful purpose for supporting CLASSIC transactions. They incur an additional gas overhead per transaction, they wrap the user’s transaction which results in issues with decoding, and now there is an issue with tracking the gas used for refunding the relayer. Bye bye tech debt!
I hope you enjoyed this small insight from our side. Never a boring day in Ethereum :)