Allow multiple parallel settlements from one solver#4167
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces parallel settlement submission for a single solver using EIP-7702 delegation, which is a significant improvement. The implementation involves a new forwarder contract, a submission account pool, and updates to the settlement processing logic. My review identified two main issues: a critical discrepancy between the Solidity source code and its compiled artifact, and a high-severity resource leak risk in the submission account pool handling. Addressing these will improve the robustness and security of the new functionality.
MartinquaXD
left a comment
There was a problem hiding this comment.
Looks really good already.
Nice test and I like the logic of passing around account claims via channels.
There was a problem hiding this comment.
Code Review
This pull request introduces a well-designed implementation for parallel settlement submission using EIP-7702, featuring the new CowSettlementForwarder contract, SubmissionAccountPool, and robust delegation setup. However, a critical privilege escalation vulnerability was identified in the CowSettlementForwarder smart contract, where an approved caller can use the forward function to modify the whitelist via setApprovedCallers on the solver EOA. This requires mitigation by preventing self-calls in the forward function. Additionally, a high-severity issue exists in the AccountGuard's drop implementation, which could lead to a panic during application shutdown.
| function forward(address target, bytes calldata data) external payable { | ||
| if (!isApprovedCaller[msg.sender]) revert Unauthorized(); | ||
| (bool success, bytes memory result) = target.call{value: msg.value}(data); | ||
| assembly { | ||
| switch success | ||
| case 0 { revert(add(result, 32), mload(result)) } | ||
| default { return(add(result, 32), mload(result)) } | ||
| } | ||
| } |
There was a problem hiding this comment.
The forward function allows an approved caller to execute arbitrary calls as the solver EOA. If the target is set to address(this), the call is executed with msg.sender set to the solver EOA itself. This allows an approved caller to call setApprovedCallers and modify the whitelist, bypassing the intended access control which requires msg.sender == address(this). This is a privilege escalation vulnerability where a submission EOA can take full control over the whitelist and potentially other sensitive functions of the solver EOA.
Recommendation: Add a check in the forward function to ensure that the target is not address(this).
function forward(address target, bytes calldata data) external payable {
if (!isApprovedCaller[msg.sender]) revert Unauthorized();
if (target == address(this)) revert Unauthorized();
(bool success, bytes memory result) = target.call{value: msg.value}(data);
assembly {
switch success
case 0 { revert(add(result, 32), mload(result)) }
default { return(add(result, 32), mload(result)) }
}
}
| if let Some(account) = self.account.take() { | ||
| let sender = self.release_sender.clone(); | ||
| tokio::spawn(async move { | ||
| if sender.send(account).await.is_err() { | ||
| tracing::error!("failed to return submission account to pool: channel closed"); | ||
| } | ||
| }); | ||
| } |
There was a problem hiding this comment.
Using tokio::spawn directly within a Drop implementation can lead to a panic if the AccountGuard is dropped while the Tokio runtime is shutting down. This would cause an ungraceful shutdown. A more robust approach is to acquire a handle to the current runtime and spawn the task on it, which gracefully handles cases where the runtime is not available or is shutting down.
if let Some(account) = self.account.take() {
let sender = self.release_sender.clone();
if let Ok(handle) = tokio::runtime::Handle::try_current() {
handle.spawn(async move {
if sender.send(account).await.is_err() {
tracing::error!("failed to return submission account to pool: channel closed");
}
});
} else {
// This can happen during a panic or when the runtime is shutting down.
// The account is not returned to the pool, which is acceptable.
tracing::warn!("no tokio runtime, submission account will not be returned to pool");
}
}
MartinquaXD
left a comment
There was a problem hiding this comment.
Looks really good over all. Mostly nits and code golf suggestions.
Comments on the contract code mostly have production deployment in mind but for e2e tests it's definitely fine.
|
|
||
| /// @notice Set approved callers. | ||
| function setApprovedCallers(address[] calldata callers, bool approved) external { | ||
| if (msg.sender != address(this)) revert Unauthorized(); |
There was a problem hiding this comment.
I might be missing some solidity details but it seems like the way to use this contract is to first have an EOA delegate to it and then send a message from the EOA to itself to call this function. Is that correct?
IIRC the state (allow list mapping) will be stored in the EOA so theoretically all solvers could use the same instance of this contract, right?
We should probably address the question whether we want to have a function cleaning up the EOAs storage or leave things as is and consider the EOAs as forever tainted once they use this contract. Probably not a big concern since attacks would require the EOA to delegate to a second malicious contract that exploits the set state.
| /// is deployed once and shared across all solver EOAs — each gets its own | ||
| /// independent `isApprovedCaller` mapping in its own storage. | ||
| contract CowSettlementForwarder { | ||
| mapping(address => bool) public isApprovedCaller; |
There was a problem hiding this comment.
Storing the approved addresses in storage means we'd have to do an additional SLOAD for every settlement. Not sure if the SC team will have concerns about that. At least for e2e testing purposes this contract seems to be sufficient.
There was a problem hiding this comment.
Is there an alternative? I can only think of baking the submission EOAs into the contract itself via a constructor, but that would make it very inflexible to update.
Also SOAD is 2100 gas which is ~nothing compared to the total costs of forwarding the calldata. 🤔
There was a problem hiding this comment.
Yes, baking the addresses into the contract is what some external solvers teams did when they rolled their own 7702 solution. I think the SC team should ultimately give the green light for the final contract. Just wanted to point that out since @fedgiac always sounds so scared about introducing SLOADs. 😄
| const POLL_INTERVAL: Duration = Duration::from_secs(3); | ||
| const MAX_WAIT: Duration = Duration::from_secs(90); |
There was a problem hiding this comment.
This is a potentially long restart that can ultimately lead to a panic. I'm not really sure if the driver should automatically set up new callers TBH. Have to think more about it.
There was a problem hiding this comment.
I was thinking about doing this outside of the driver, but we're using KMS here which the driver is well equipped to use unlike a script where you need AWS access.
It is one-time cost, I think the submission acc set is not really going to be changing often at all.
fa14015 to
a498412
Compare
| fn deref(&self) -> &Account { | ||
| &self.account | ||
| impl SubmitterGuard { | ||
| fn delegation_context(&self) -> Option<DelegatedSubmission> { |
There was a problem hiding this comment.
I wonder if it would be nicer to return an enum here so that we don't have to rely on the tx having the correct from set in the Direct case. 🤔
There was a problem hiding this comment.
Mhm. We would have to carry around the solver address in Direct as well we wouldn't actually get to delete wrap_for_delegated_submission, so I don't think it's a big win.
There was a problem hiding this comment.
This was not about deleting any code - just about making the important information available in the same place. It's a bit strange that submission data for EIP 7702 comes from the SubmitterPool while direct submission relies on the tx being created with the correct from address.
There was a problem hiding this comment.
31d1eec to
d679caa
Compare
MartinquaXD
left a comment
There was a problem hiding this comment.
All my comments got addressed. 👍
Description
Resolves #3966.
Enable parallel settlement submission from a single solver by using EIP-7702 delegation. When a solver wins multiple solutions in overlapping auctions, each settlement can now be submitted concurrently through independent submission EOAs instead of waiting in a sequential queue.
The solver EOA delegates its code to a CowSettlementForwarder contract via EIP-7702. Approved submission EOAs call forward(target, data) on the solver EOA, which forwards to the actual target (settlement contract, wrapper, or flashloan router) with msg.sender = solver EOA — preserving the existing whitelisting.
Changes
CowSettlementForwarder.sol— New forwarder contract with caller whitelist. Supports forwarding to any target (settlement contract, wrappers, flashloan router) viaforward(address target, bytes data)SubmissionAccountPooltype: a channel-based pool that lends submission accounts for the duration of a settlement and reclaims them afterward. In EIP-7702 mode, settle requests are spawned as concurrent tasks instead of processed sequentiallysubmission_accountsconfig that allows specifying accounts that are whitelisted to submit settlements. If omitted we fallback to non-delegated submissions directly from the solver EOARequirements before deployment
Only then can the submissions accounts be configured and deployed.
How to test
cargo nextest run -p e2e local_node_parallel_settlement --test-threads 1 --run-ignored ignored-only