# BLSWallet: Notes for tx.origin payment research ## Key Findings - PBS/MEV-boost/flashbots are L1 only, so not relevant (need to look into other MEV stuff though) - USDC transactions on Arbitrum with BLSWallet might cost under 1c (USD) - A subsidy could be effective in enabling early adopters to pay the longer-term expected cost instead of needing to cover the relatively unshared fixed bundle costs, which might cost up to USD$13,000/month - Sending gasless transactions that pay tx.origin to RPC nodes on Arbitrum (both infura and arb1.arbitrum.io) are NOT getting run - Offering free money by simply calling collect() is also not getting picked up - EIP-1559 effectively prohibits gasless EOA transactions - Legacy transactions are interpreted as 1559 (type 2) transactions by using `gasPrice` for both `maxFeePerGas` and `maxPriorityFeePerGas` - Transactions must provide `maxFeePerGas` >= `basefee`, (the protocol ensures that `basefee` is burned) ## Next Steps - Check up on consensus details on Arbitrum ## Lower Bound for the Cost of Specialized USDC Transactions - 4 bytes: *from* - 4 bytes: *to* - (requiring some set-up cost to store a blskey mapping somewhere) - 3 bytes: *amount* - using 20 bits to encode 6 digits and 4 bits to encode 16 different unit sizes ranging from 0.01c to $100b (e.g. $123.456 is encoded using 123456 for the digits and 0.1c for the unit size) - 0 bytes: *reward amount* - omitted by approving a contract which can pay out a programmatic amount set by an oracle or just some formula of basefee - 0 bytes: *nonce* - just read it from the scw (BLSWallet.sol) So 11 bytes for the marginal calldata needed. With calldata costing 16 gas / byte, that's 176 gas, which currently costs about USD$0.003. I'm concerned there could be an added cost of writing this calldata to storage on L1? Need to clarify this. ## Subsidy There's a fixed cost associated with each bundle: - Calldata - 20 bytes: *address of tx.origin* - 20 bytes: *address of VG/expander* - 4 bytes: *function header* - 64 bytes: *bls signature* - 1 byte: *number of operations* - Evaluation - Difficult to estimate The calldata can be expected to cost about USD$0.03, 10x higher than the calldata cost of an individual tx. For now, let's estimate the fixed cost is 10x higher in general. The fixed cost needs to be shared by the transactions of the bundle. This will disproportionately affect early adopters since they will have fewer users to share the cost. A subsidy could help here. It could be sustainable too - structured carefully, the subsidy could be removed as adoption goes up, producing a net zero impact. If successful, users could have the same (and possibly lower) fees after the subsidy is discontinued. ### Implementation Strategies The simplest way to implement this subsidy would be to just provide an aggregator that's willing to submit bundles at a loss. However, this could interfere with independent aggregators, creating a long-term reliance on our aggregator. One possibility would be to link the expander to a subsidy contract which pays `tx.origin` when the calldata is small. However, this is vulnerable to exploitation as an aggregator could simply provide non-genuine transactions to ensure the profitable operation continues even when no genuine users are making transactions. The aggregator only extracts a small portion of the subsidy since most of it needs to be paid in fees, but this still results in the subsidy being wasted. Running an aggregator that operates at a loss seems like the best way to provide a subsidy. It's just important to still require a fee that can reasonably lead to a market where aggregators are profitable (except perhaps for a promotional period). The required fee should still cover the marginal cost of the transaction, plus enough headroom to plausibly cover the fixed cost in a future with more users. Note: It's possible to lower what we consider the marginal cost to be to account for future improvements like sharding. If our aggregator starts turning a profit, it should be possible to outcompete us by lowering the fee required by users or tipping the validators, and we could communicate this publicly to encourage independent operators. ### Cost An upper bound for the cost of this subsidy depends on: - how frequently we're willing to submit unprofitable bundles (which contributes to transaction latency) - the actual fixed cost of submitting a bundle Suppose that: - we submit unprofitable bundles every 10 seconds (if needed) - the fixed cost is USD$0.05 (by inflating the USD$0.03 calldata estimate to allow for evaluation and gas price fluctuation) Then the upper bound is **USD$13,000/month**. Note: - when usage is low, we'll be submitting bundles less often (perhaps *much* less), resulting in lower cost - in the event that we actually subsidize USD$13,000 in a month, it must have supported about 260,000 transactions, and probably more like 400,000 (since if there's a tx every 10 seconds, many of those 10 second windows must have additional txs by chance) - it would probably make sense to reduce the bundling delay after a period when no transactions have been received, since we can still wait 10+ seconds between bundles, and making transactions faster when it's quiet would help encourage early adopters ## MEV-boost **Doesn't this only apply on L1?** mev-boost says they have 37% adoption: https://boost.flashbots.net/mev-boost-status-updates/mev-boost-status-update-sep-9-sept-22-2022 looks like 59% is more up-to-date - see "Percentage of blocks proposed by Flashbots MEV-Boost Relay vs. other by day" https://transparency.flashbots.net/ It's the *relays* that build the blocks (in this ecosystem). So they're the ones that would need to learn about bundle aggregation to get the best experience. We could also make our own relay. ## Generalizing the Expander If we specialize on USDC transactions, there's a risk of creating an unnecessarily separate bundling market for those transactions. This is because the simplest implementation would have that specialized expander call `VerificationGateway.processBundle` directly, which means it accepts a signature tied only to those specialized transactions. Reasons that a separate bundling market is bad: - Higher fees due to splitting the fixed bundle cost between fewer users - Longer bundling delays since fewer users take longer to fill a profitable bundle Instead, specialized expanders should be designed to expand transaction data only and not accept a signature. A generalized expander, by contrast, could accept a payload that delegates to specialized expanders as needed, and *then* calls `VerificationGateway.processBundle`. Specialized expanders could be registered with the generalized expander to allow specifying which specialized expander is being used in perhaps a byte or two ([VLQ](https://en.wikipedia.org/wiki/Variable-length_quantity)). ## Arbitrum One Experiments ``` Amount Fee Total Description =================================================================================== 0.010000 0.000298 0.010298 Personal ETH sent to 0xE99f..694d for experimentation 0.008200 0.001347 0.009547 Bridged to Arbitrum One 0.008178 0.000013 0.008190 Transfer max into 0x6F6c..186D explorer.arbitrum.io not seeing tx... :( Consistent with l2fees.info showing 2-3c for ETH transfer (Note: discovered later that arbiscan.io is a functioning explorer for Arbitrum. Not sure whether this was available at the time. explorer.arbitrum.io is still the default Arbitrum explorer in Metamask.) 0.008155 0.000012 0.008167 Transfer max back to 0xE99f..694d Metamask leaves behind 0.000010 dust due to gas headroom 0.000000 0.000111 0.000111 Deployed TxOriginUtil to 0x5c19..0517 Fee was estimated as 0.000018, not sure what happened, explorer.arbitrum.io is not working Not 100% sure about fee, needed to infer based on other fees noted here... again, block explorer not working. (See previous note about arbiscan.io.) I've realized this isn't useful for my EOA. If I sign for sendEthToTxOrigin (with gasPrice:0), there's no way to run that transaction from a different origin. It might be possible to include the transaction, but tx.origin is going to be my EOA, not an MEV account. 0.000000 0.000117 0.000117 Decided to just deploy TxOriginUtil again to check my fees (still only like 16c to do this). Deployed to 0xD91a..EEEA. This time the estimate was 0.000019, not sure what's wrong with my estimation: const TxOriginUtil = await ethers.getContractFactory("TxOriginUtil"); const [estimatedGas, gasPrice] = await Promise.all([ ethers.provider.estimateGas({ data: TxOriginUtil.interface.encodeDeploy(), }), ethers.provider.getGasPrice(), ]); console.log({ estimatedGas, gasPrice, fee: `${ethers.utils.formatEther(estimatedGas.mul(gasPrice))} ETH`, }); New balance: 0.007937. 0.000000 0.000016 0.000016 Sent 0.000100 to sendEthToTxOrigin with automatic settings as a quick check. Worked correctly. gasUsed * gasPrice matches the fee. New balance: 0.007921. NA NA NA Attempted to send 0.000100 to sendEthToTxOrigin with gasPrice:0. Got ProviderError: HttpProviderError. This could be an infura issue. Balance unchanged after 3 minutes. 0.000000 0.000015 0.000015 Sent 1 wei to self to invalidate pending tx. New balance: 0.007906. NA NA NA Attempted to send 0.000100 to sendEthToTxOrigin with gasPrice:0 and using https://arb1.arbitrum.io/rpc. Still got ProviderError: HttpProviderError. Balance unchanged after 3 minutes. 0.000000 0.000015 0.000015 Sent 1 wei to self to invalidate pending tx. New balance: 0.007891. 0.000000 0.000182 0.000182 Deployed ApproverSCW to 0x426e..21B6. New balance: 0.007709. 0.001000 0.000012 0.001012 Sent 0.001 ETH to ApproverSCW. New balance: 0.006697. 0.000000 0.000016 0.000016 Approved sending 0.001 ETH back to me. New balance: 0.006682. 0.000000 0.000029 0.000029 Performed 0.001 ETH sent back to me. Round trip total cost: 0.000057 (10c). New balance: 0.007653. 0.000000 0.000017 0.000017 Approved sending 0.0001 ETH to tx.origin. Note the extra zero ^ above. New balance: 0.007636. 0.001000 0.000012 0.000012 Sent 0.001 ETH to ApproverSCW. New balance: 0.006624. NA NA NA Sent the perform for 0.0001 ETH to tx.origin with gasPrice:0. 5 minutes have passed but has not been performed. I suspect something is stopping the gasPrice:0 tx from reaching the node (aka client). NA NA NA Sent the perform for 0.0001 ETH to tx.origin with gasPrice:1. 60 minutes later, still not performed. 0.000000 0.000028 0.000028 Sent the perform with: maxFeePerGas: 500000000, maxPriorityFeePerGas: 0, This was intended to be too low, but it was run with my EOA. New balance: 0.006696. 0.000100 0.000012 0.000112 Sent 0.0001 ETH to 0x6F6c..186D, trying to specify maxFeePerGas, maxPriorityFeePerGas using window.ethereum with metamask, but it ignored them ๐Ÿ˜ . New balance: 0.006584. 0.000100 0.000011 0.000111 Sent 0.0001 ETH again, metamask seems to validate maxFeePerGas but then ignore it... ๐Ÿคจ. New balance: 0.006473. NA NA NA Failed to send 0.0001 ETH via ethers with gasLimit: 140000 gasPrice: 50000000 which makes sense because it enforces a max fee of 0.000007 ETH, which is below market. Importantly though, it fails with the same HttpProviderError. Suggests that when setting zero for these fields, it still is actually going to the node. 0.000100 0.000011 0.000111 Sent 0.0001 ETH via ethers with: gasLimit: 140000 gasPrice: 100000000 which worked, but only consumed 0.000011 in fees. New balance: 0.006362. I've done some digging and confirmed that the tx is being sent to the node (aka client) when getting the HttpProviderError. It appears that Arbitrum is simply not picking up the MEV opportunity. 0.000100 0.000035 0.000035 Deployed FreeMoney (๐Ÿ˜…) to 0xbF31..FbFC. Provided 0.0001 ETH for the contract on construction, so that money is sitting there just waiting for anyone to call collect(). New balance: 0.006227. 0.000291 0.000011 0.000302 Sent ETH from 0x6F6c..186D back to 0xE99f..694d. New balance: 0.006518. 0.003000 0.000059 0.003059 Used Uniswap, received USDC$4.711. New balance: 0.003459. 0.000000 0.000022 0.000022 Sent 1 USDC to 0x6F6c..186D. Sending USDC on arbitrum costs about 3.5c. This is different from 'swap tokens' listed on l2fees.info, which accurately reflects the ETH<->USDC swap above. New balance: 0.003438. NA NA NA Called freeMoney.collect but with maxFeePerGas: 50000000 failed in expected way. Trying to prompt Arbitrum about the free money. It's been an hour and it's still sitting there. 0.000900 0.000011 0.000911 Sent another 0.0009 to FreeMoney. Now calling collect will return 0.001 ETH ($1.57). Still small, but definitely worth picking up if you notice it (only 16c before). 0.000000 0.000017 0.000017 Called freeMoney.collect (using defaults). Recovered 0.001 ETH paying 0.000017 ETH in fees for a profit of 0.000983 ETH ($1.55). I left this overnight, the opportunity was there for over 15 hours. New balance: 0.003510 0.000000 0.000028 0.000028 Approve uniswap to use USDC. 0.000000 0.000068 0.000068 Swapped USDC$3.711 for 0.002362 ETH. New balance: 0.005777 0.000000 0.000021 0.000021 Approved sending 0.0009 ETH to 0x6F6c..186D New balance: 0.005756 0.000000 0.000039 0.000039 Performed sending 0.0009 ETH to 0x6F6c..186D New balance: 0.005717 0.000000 0.000026 0.000026 Sent USDC$1.000 from 0x6F6c..186D to 0xE99f..694d 0.000856 0.000015 0.000871 Sent 0.000856 ETH from 0x6F6c..186D to 0xE99f..694d New balance: 0.006572 0.000000 0.000061 0.000061 Swapped USDC$1.000 for 0.000640 ETH. New balance: 0.007151 0.007128 0.000013 0.007141 Sent 0.007128 ETH to 0xd423..FB51 (andrewmorris.eth). I originally spent 0.010298 ETH, so this experiment cost me 0.003170 ETH ($4.98) Note that I haven't bridged back to L1, otherwise I'd expect an additional cost of 0.001347 ETH. Not concerned about recovering these costs. Abbreviation Full 0xE99f..694d 0xE99f0c7f121ACCfa3eE5f57422cbC2b5Fad1694d 0x6F6c..186D 0x6F6c0968793E2470E80fC60a3edDB4E6F83B186D 0x5c19..0517 0x5c19B58e84Eedfd684002e0900f2418641850517 0xD91a..EEEA 0xD91ab2474F5578abDA0cEa626c32E19C406AEEEA 0x426e..21B6 0x426ebdfd85A021780563508D2E8e8705be9A21B6 0xbF31..FbFC 0xbF31a3b41FEDCa9d29C97c39Db862a23Cc09FbFC 0xd423..FB51 0xd4238816E3d67D114242791F193915d22BBDFB51 ``` (Note: Fiat amounts are in USD unless specified, including cents marked with 'c'. Currently 1c is about 6 microETH.) ```solidity= // SPDX-License-Identifier: Unlicense pragma solidity ^0.8.9; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; // Uncomment this line to use console.log // import "hardhat/console.sol"; contract TxOriginUtil { function sendEthToTxOrigin() external payable { bool sent = payable(tx.origin).send(msg.value); require(sent, "Failed to send Ether"); } function sendTokenToTxOrigin(IERC20 token, uint256 amount) external { token.transferFrom(msg.sender, tx.origin, amount); } } ``` ```solidity= // SPDX-License-Identifier: Unlicense pragma solidity ^0.8.9; struct Action { address to; uint256 value; bytes data; } contract ApproverSCW { address owner; bytes32 approvedActionsHash; constructor() { owner = msg.sender; } receive() external payable {} function approve(bytes32 actionsHash) external { require(msg.sender == owner); approvedActionsHash = actionsHash; } function perform(Action[] calldata actions) external { require(keccak256(abi.encode(actions)) == approvedActionsHash); approvedActionsHash = 0; for (uint i = 0; i < actions.length; i++) { Action calldata a = actions[i]; bool success; if (a.value > 0) { (success, ) = payable(a.to).call{value: a.value}(a.data); } else { (success, ) = address(a.to).call(a.data); } require(success); } } } ``` ```solidity= // SPDX-License-Identifier: Unlicense pragma solidity ^0.8.9; contract FreeMoney { constructor() payable {} receive() external payable {} function collect() external { payable(msg.sender).transfer( address(this).balance ); } } ``` ## EIP-1559 Effectively Prohibits Gasless EOA Transactions EIP-1559 introduces type 2 transactions which include the fields `maxFeePerGas`, `maxPriorityFeePerGas`. Previous transaction types (mainly legacy transactions) are supported by normalizing them to their effective type 2 transactions: ```python= if isinstance(transaction, TransactionLegacy): return NormalizedTransaction( signer_address = signer_address, signer_nonce = transaction.signer_nonce, gas_limit = transaction.gas_limit, max_priority_fee_per_gas = transaction.gas_price, max_fee_per_gas = transaction.gas_price, destination = transaction.destination, amount = transaction.amount, payload = transaction.payload, access_list = [], ) ``` (Code excerpt from the [EIP-1559 spec](https://github.com/ethereum/EIPs/blob/master/EIPS/eip-1559.md).) Basically, `gasPrice` is copied over to `maxFeePerGas`, `maxPriorityFeePerGas`. This means the sender of the transaction effectively pays the same price as before 1559, since any excess over the basefee will be given to the miner/validator as a priority fee. However, before 1559, miners were free to include any valid transactions they wished up to the size of the block, and many would do so (geth default?) if there was unused block space for them. 1559 changes this by requiring that `maxFeePerGas >= basefee`: ```python= # ensure that the user was willing to at least pay the base fee assert transaction.max_fee_per_gas >= block.base_fee_per_gas ``` (Code excerpt from the [EIP-1559 spec](https://github.com/ethereum/EIPs/blob/master/EIPS/eip-1559.md).) Since `maxFeePerGas == gasPrice` for legacy transactions, `gasPrice == 0` would only work if `basefee == 0`, which isn't expected to occur.