Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
FORK_RPC_URL=
BASE_RPC_URL=
COW_HISTORICAL_TX_HASHES=
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
env:
FORK_RPC_URL: ${{ secrets.FORK_RPC_URL }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # action v6.0.2
with:
Expand Down
5 changes: 4 additions & 1 deletion .solhint.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
"rules": {
"compiler-version": ["error", "^0.8"],
"import-path-check": "off",
"use-natspec": "warn"
"use-natspec": "warn",
"func-visibility": ["error", { "ignoreConstructors": true }],
"no-complex-fallback": "off",
"no-inline-assembly": "off"
Comment on lines +7 to +9
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All of these are necessary:

  • "func-visibility": ["error", { "ignoreConstructors": true }] -> constructor doesn't require explicit visibility modifier since Solidity 0.7
  • "no-complex-fallback": "off" -> we explicitly need our fallback to be complex
  • "no-inline-assembly": "off" -> we explicitly need assembly for gas efficiency

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

id almost say that no-inline-assembly should be added to the template repo because we almost end up using it for one reason or another.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd sit on that as it might not be required by other repos.

}
}
15 changes: 11 additions & 4 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ slither:

# Run tests
test:
forge test -vvv --show-progress --gas-snapshot-check true
forge test -vvv --show-progress

# Print coverage summary
coverage-summary:
Expand All @@ -51,9 +51,17 @@ coverage-lcov:

# Fail if the minimum of all four coverage metrics (lines/statements/branches/funcs) on the `Total` row is below `COVERAGE_MIN` (default `100`)
coverage-check:
@{{JUST}} coverage-summary > coverage.txt
cat coverage.txt
# Fields on the `| Total | ... |` row are: $4=lines, $7=statements, $10=branches, $13=funcs (whitespace-split, `%` stripped)
@tmp_dir="$(mktemp -d)"; \
snapshot_patch="$tmp_dir/snapshots.patch"; \
git diff --binary -- snapshots > "$snapshot_patch"; \
cleanup() { git restore --worktree snapshots; if [ -s "$snapshot_patch" ]; then git apply "$snapshot_patch"; fi; rm -rf "$tmp_dir"; rm -f coverage.txt; }; \
trap cleanup EXIT; \
if ! {{JUST}} coverage-summary > coverage.txt 2>&1; then \
cat coverage.txt; \
exit 1; \
fi; \
cat coverage.txt; \
awk -v threshold={{COVERAGE_MIN}} '\
BEGIN { labels[4]="lines"; labels[7]="statements"; labels[10]="branches"; labels[13]="funcs"; min=100; below="" } \
/^\| Total/ { \
Expand All @@ -69,7 +77,6 @@ coverage-check:
if (!found) { print "Failed to extract coverage percentage."; exit 1 } \
if (min < threshold) { printf "\nMetrics below minimum threshold of %s%%:\n%s\n", threshold, below; exit 1 } \
}' coverage.txt
rm coverage.txt

# Generate gas snapshots
snapshot:
Expand Down
56 changes: 50 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
# Contract Template
# Solver7702Delegate

Template for creating new smart contract projects.
`Solver7702Delegate` is a minimal ERC-7702 delegation target for CoW Protocol solvers. It lets a solver keep using its existing solver EOA while allowing a fixed set of auxiliary EOAs to submit transactions through that solver EOA. The main benefit is parallel settlement submission: auxiliary EOAs provide independent nonce lanes, while downstream contracts still see the solver EOA as `msg.sender`, which keeps the authorization clean.

This project is meant to be used as a templated during the creation of new Github repositories (will show in the `Create a new repository > Configuration > Start with a template` selector).

It will contain some useful configuration files and scripts, that can be used also with existing projects (manually copied).
Read more about the initiative [here](https://www.notion.so/cownation/Solver7702Delegate-Design-Doc-3588da5f04ca80a1b521c436abf17724).
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

linking to a private notion on a public repo's readme seems a bit wierd. not sure if we want this here or not.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, I feel like it should be there for any internal integrators.


## Usage

Expand All @@ -28,6 +26,25 @@ If specific features are needed (like PUSH0 in 0.8.20 for gas optimizations or t
just test
```

#### Replaying Your Own Historical Transactions

The fork test `test_fork_historicalTransaction_directVsDelegated_userSuppliedTxHashes` lets you replay your own batch transactions through the delegate.

Set:

- `FORK_RPC_URL` to the RPC URL you want Foundry to fork from.
- `COW_HISTORICAL_TX_HASHES` to a comma-separated list of transaction hashes.

The supplied transaction hashes just need to exist on that network, and the RPC must support the historical state needed by `vm.rollFork(txHash)`.

Example:

```shell
FORK_RPC_URL=<your_rpc_url> \
COW_HISTORICAL_TX_HASHES=0xabc...,0xdef... \
just test --match-test test_fork_historicalTransaction_directVsDelegated_userSuppliedTxHashes
```

### Format

```shell
Expand Down Expand Up @@ -93,8 +110,35 @@ just snapshot

### Deploy

The deploy script reads up to five approved caller addresses from `APPROVED_CALLERS`.
If fewer than five addresses are needed, omit the rest.

```shell
export APPROVED_CALLERS=<approved_caller_0>,<approved_caller_1>

just forge script script/DeploySolver7702Delegate.s.sol:DeploySolver7702Delegate \
--rpc-url <your_rpc_url> \
--private-key <your_private_key> \
--broadcast
```

Deployments use `CREATE2` with a zero salt by default. To use a different salt, pass a `bytes32` value:

```shell
export SALT=<bytes32_salt>

just forge script script/DeploySolver7702Delegate.s.sol:DeploySolver7702Delegate \
--rpc-url <your_rpc_url> \
--private-key <your_private_key> \
--broadcast
```

To simulate the deployment and inspect the computed address, run the same command without `--broadcast`:

```shell
forge script script/Counter.s.sol:CounterScript --rpc-url <your_rpc_url> --private-key <your_private_key>
just forge script script/DeploySolver7702Delegate.s.sol:DeploySolver7702Delegate \
--rpc-url <your_rpc_url> \
--private-key <your_private_key>
```

## New project creation checklist
Expand Down
4 changes: 2 additions & 2 deletions dev/package.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"name": "contracts-template-dev",
"name": "solver-7702-delegate",
"version": "0.0.0",
"private": true,
"description": "Local development dependencies for the contracts template.",
"description": "Local development dependencies for the Solver7702Delegate contract.",
"license": "(MIT OR Apache-2.0)",
"packageManager": "pnpm@10.33.2",
"devDependencies": {
Expand Down
4 changes: 3 additions & 1 deletion foundry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ src = "src"
out = "out"
libs = ["lib"]
solc = "0.8.34" # See latest release at: https://github.com/argotorg/solidity/releases
isolate = true

[fmt]
sort_imports = true
number_underscore = "thousands"
wrap_comments = true
Comment thread
igorroncevic marked this conversation as resolved.

[profile.ci]
deny = "warnings" # Why not always: sometimes you just want to code and see what comes out
verbosity = 3 # Outputs stack traces for failed tests.
fuzz.seed = "0"
fuzz.runs = 10_000
fuzz.runs = 10_000
20 changes: 0 additions & 20 deletions script/Counter.s.sol

This file was deleted.

37 changes: 37 additions & 0 deletions script/DeploySolver7702Delegate.s.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
pragma solidity ^0.8.34;

import {Script} from "forge-std/Script.sol";

import {Solver7702Delegate} from "../src/Solver7702Delegate.sol";

/// @title DeploySolver7702Delegate
/// @author CoW Foundation
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in the past we have used CoW DAO developers most frequently ex.

https://github.com/cowprotocol/euler-integration-contracts/blob/master/src/CowWrapper.sol#L16
https://github.com/cowprotocol/flash-loan-router/blob/main/src/FlashLoanRouter.sol#L13

(Other contracts have used "CoW Swap Developers" or CoW Developers, so at the very least lets not add a whole nother author to the mix)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As per cowprotocol/contracts-template#20 and legal recommendations, CoW Foundation is the name we should use going forward.

/// @notice Deploys Solver7702Delegate.
contract DeploySolver7702Delegate is Script {
uint256 internal constant APPROVED_CALLERS_LENGTH = 5;

error InvalidApprovedCallersLength(uint256 length);

/// @notice Deploys Solver7702Delegate using approved caller environment variables.
/// @return solver7702Delegate The deployed Solver7702Delegate contract.
function run() external returns (Solver7702Delegate solver7702Delegate) {
vm.startBroadcast();
solver7702Delegate = new Solver7702Delegate{salt: vm.envOr("SALT", bytes32(0))}(_getApprovedCallers());
vm.stopBroadcast();
}

/// @notice Reads the approved caller addresses from environment variables.
/// @return approvedCallers The approved caller addresses.
function _getApprovedCallers() internal view returns (address[APPROVED_CALLERS_LENGTH] memory approvedCallers) {
address[] memory rawApprovedCallers = vm.envAddress("APPROVED_CALLERS", ",");

if (rawApprovedCallers.length == 0 || rawApprovedCallers.length > APPROVED_CALLERS_LENGTH) {
revert InvalidApprovedCallersLength(rawApprovedCallers.length);
}

for (uint256 i; i < rawApprovedCallers.length; ++i) {
approvedCallers[i] = rawApprovedCallers[i];
}
}
}
2 changes: 1 addition & 1 deletion slither.config.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"detectors_to_exclude": "solc-version",
"detectors_to_exclude": "solc-version,naming-convention,assembly",
Comment thread
igorroncevic marked this conversation as resolved.
"filter_paths": "/(lib|test|script)/"
}
4 changes: 4 additions & 0 deletions snapshots/Solver7702DelegateForkTest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"historical tx - swap and bridge order - delegated call": "949703",
"historical tx - swap and bridge order - direct call": "945548"
}
11 changes: 11 additions & 0 deletions snapshots/Solver7702DelegateTest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"approved caller empty target payload - success - forwards": "24507",
"approved caller short calldata - success - receives ETH": "21760",
"approved caller short calldata - success - returns empty": "21760",
"approved caller target empty revert - reverts - bubbles empty data": "24861",
"approved caller target payload - success - forwards": "31258",
"approved caller target return data - success - bubbles return data": "25069",
"approved caller target revert data - reverts - bubbles non-empty data": "25738",
"unauthorized caller - success - receives ETH": "21232",
"unauthorized caller no value - reverts - unauthorized": "21930"
}
21 changes: 0 additions & 21 deletions src/Counter.sol

This file was deleted.

88 changes: 88 additions & 0 deletions src/Solver7702Delegate.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
pragma solidity ^0.8.34;

/// @title Solver7702Delegate
/// @author CoW Foundation
/// @notice ERC-7702 delegation target for solver EOAs
contract Solver7702Delegate {
/// @notice Error thrown when a caller is unauthorized
error Unauthorized(address sender);

/// @notice Address of the first approved caller
address private immutable APPROVED_CALLER_0;

/// @notice Address of the second approved caller
address private immutable APPROVED_CALLER_1;

/// @notice Address of the third approved caller
address private immutable APPROVED_CALLER_2;

/// @notice Address of the fourth approved caller
address private immutable APPROVED_CALLER_3;

/// @notice Address of the fifth approved caller
address private immutable APPROVED_CALLER_4;
Comment on lines +11 to +24
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I left these as private as to not increase the bytecode of the contract unnecessarily.

However, there would need to be some additional assumptions in services about this, because now we can no longer check if the caller is approved with isApprovedCaller (first iteration), but through some other means, e.g. simulating a transaction with a sender being e.g. Bob, and see if it would revert with Unauthorized.

Copy link
Copy Markdown

@kaze-cow kaze-cow May 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is a major issue because if the contract is deployed with CREATE2, then the correct list of callers can be validated by checking the existance of the code at the expected address.

Most ethereum libraries provide some sort of a function to predict the create2 address, such as get_create2_address_from_hash. No relying on simulation/revert on creation check needed.

To then know which addresses are authorized, the list can be read from the driver instance that created it.


/// @notice Constructor to initialize the approved callers
/// @param approvedCallers The addresses of the approved callers
constructor(address[5] memory approvedCallers) {
APPROVED_CALLER_0 = approvedCallers[0];
APPROVED_CALLER_1 = approvedCallers[1];
APPROVED_CALLER_2 = approvedCallers[2];
APPROVED_CALLER_3 = approvedCallers[3];
APPROVED_CALLER_4 = approvedCallers[4];
}

/// @notice Fallback function to handle calls to the delegate
/// @dev Expected calldata format is `bytes20(target) || targetCalldata`.
fallback() external payable {
// Possibly short circuit by recognizing one of the approved callers
if (
msg.sender == APPROVED_CALLER_0 || msg.sender == APPROVED_CALLER_1 || msg.sender == APPROVED_CALLER_2
|| msg.sender == APPROVED_CALLER_3 || msg.sender == APPROVED_CALLER_4
) return _callThrough();

// Accept ETH from anyone, even if unauthorized
if (msg.value > 0) return;
revert Unauthorized(msg.sender);
}
Comment thread
igorroncevic marked this conversation as resolved.

function _callThrough() internal {
// Receive ETH and exit when no target address is encoded.
if (msg.data.length < 20) return;

// Extract the first 20 bytes of calldata as the target address.
address target = address(bytes20(msg.data[0:20]));
Comment thread
igorroncevic marked this conversation as resolved.

assembly {
// Extract calldata in range (target, len(msg.data)).
// We take full control of memory in this inline assembly block because it will not return to Solidity code.
// This is why we overwrite the Solidity scratch pad at memory position 0.
calldatacopy(0x00, 20, sub(calldatasize(), 20))

// Call the implementation
let result :=
call(
gas(), // gas - forward all of it
target, // target to call
callvalue(), // value - forward all Ether
0x00, // input offset - pointer to calldata
sub(calldatasize(), 20), // input size - length of calldata
0x00, // output offset - read via returndatacopy below
0x00 // output size - read via returndatacopy below
)

// Copy return data into memory
returndatacopy(0x00, 0x00, returndatasize())

// Handle return data, 0 = revert / 1 = success
switch result
case 0 {
revert(0x00, returndatasize())
}
default {
return(0x00, returndatasize())
}
}
}
}
5 changes: 5 additions & 0 deletions test/.solhint.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
{
"rules": {
"func-name-mixedcase": "off",
"gas-strict-inequalities": "off",
"gas-small-strings": "off",
"avoid-low-level-calls": "off",
"max-states-count": "off",
"no-empty-blocks": "off",
"use-natspec": "off"
}
}
Loading
Loading