diff --git a/packages/evm/contracts/Settler.sol b/packages/evm/contracts/Settler.sol index 8a30061..062a7ec 100644 --- a/packages/evm/contracts/Settler.sol +++ b/packages/evm/contracts/Settler.sol @@ -21,6 +21,7 @@ import '@openzeppelin/contracts-upgradeable/utils/cryptography/EIP712Upgradeable import '@openzeppelin/contracts/token/ERC20/IERC20.sol'; import '@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol'; import '@openzeppelin/contracts/utils/cryptography/ECDSA.sol'; +import '@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol'; import './Intents.sol'; import './interfaces/IController.sol'; @@ -42,6 +43,7 @@ contract Settler is ISettler, Initializable, OwnableUpgradeable, ReentrancyGuard using IntentsHelpers for Intent; using IntentsHelpers for Proposal; using IntentsHelpers for Validation; + using SafeguardsHelpers for SafeguardAuthorization; using SmartAccountsHandlerHelpers for address; // Mimic controller reference @@ -62,6 +64,9 @@ contract Settler is ISettler, Initializable, OwnableUpgradeable, ReentrancyGuard // Safeguard config per user mapping (address => bytes) internal _userSafeguard; + // Whether a safeguard nonce was already used by a user + mapping (address => mapping (uint256 => bool)) public override isUserSafeguardNonceUsed; + /** * @dev Modifier to tag settler functions in order to check if the sender is an allowed solver */ @@ -177,6 +182,32 @@ contract Settler is ISettler, Initializable, OwnableUpgradeable, ReentrancyGuard _setSafeguard(msg.sender, safeguard); } + /** + * @dev Sets a safeguard on behalf of a user based on a signature authorized by that user. The user can be + * an EOA authorizing it with its own ECDSA signature, or a smart account implementing ERC-1271. + * @param authorization Safeguard authorization signed by the user + * @param signature EIP-712 signature authorizing the safeguard, verified with ECDSA or ERC-1271. It may be + * empty for smart accounts that track approved messages on-chain. + */ + function setSafeguardWithSignature(SafeguardAuthorization memory authorization, bytes memory signature) + external + override + { + uint256 deadline = authorization.deadline; + if (deadline <= block.timestamp) revert SettlerSafeguardPastDeadline(deadline, block.timestamp); + + address user = authorization.user; + uint256 nonce = authorization.nonce; + if (isUserSafeguardNonceUsed[user][nonce]) revert SettlerSafeguardNonceAlreadyUsed(user, nonce); + + bytes32 typedDataHash = _hashTypedDataV4(authorization.hash()); + if (!_isValidUserSignature(user, typedDataHash, signature)) revert SettlerSafeguardInvalidSignature(user); + + // Consuming the nonce makes each signature usable only once, no matter who submits it + isUserSafeguardNonceUsed[user][nonce] = true; + _setSafeguard(user, authorization.safeguard); + } + /** * @dev Executes a proposal to fulfill an intent * @param intent Intent to be fulfilled @@ -666,6 +697,22 @@ contract Settler is ISettler, Initializable, OwnableUpgradeable, ReentrancyGuard emit DynamicCallEncoderSet(newDynamicCallEncoder); } + /** + * @dev Tells whether a signature over a hash was authorized by a user, supporting both EOAs and smart + * accounts implementing ERC-1271, such as Safe. ECDSA is attempted first so that EOAs that delegated + * their code (EIP-7702) are still verified as EOAs instead of being routed to their delegate. + * Note: unlike ECDSA signatures, ERC-1271 signatures are revocable, so validity is evaluated at + * execution time and the result may change over time for the same signature. + * @param user Address that must have authorized the signature + * @param hash Hash that was signed + * @param signature Signature to be verified + */ + function _isValidUserSignature(address user, bytes32 hash, bytes memory signature) internal view returns (bool) { + (address signer, ECDSA.RecoverError err, ) = ECDSA.tryRecover(hash, signature); + if (err == ECDSA.RecoverError.NoError && signer == user) return true; + return SignatureChecker.isValidERC1271SignatureNow(user, hash, signature); + } + /** * @dev Sets a safeguard for a user * @param user Address of the user to set the safeguard for diff --git a/packages/evm/contracts/interfaces/ISettler.sol b/packages/evm/contracts/interfaces/ISettler.sol index 80d1ff5..ecf9fdb 100644 --- a/packages/evm/contracts/interfaces/ISettler.sol +++ b/packages/evm/contracts/interfaces/ISettler.sol @@ -144,6 +144,21 @@ interface ISettler { */ error SettlerTooManySafeguards(uint256 lengthRequested); + /** + * @dev The safeguard deadline is in the past + */ + error SettlerSafeguardPastDeadline(uint256 deadline, uint256 timestamp); + + /** + * @dev The safeguard signature is not authorized by the user + */ + error SettlerSafeguardInvalidSignature(address user); + + /** + * @dev The safeguard nonce was already used by the user + */ + error SettlerSafeguardNonceAlreadyUsed(address user, uint256 nonce); + /** * @dev The chains of a swap operation do not match the swap type (single or cross chain) */ @@ -241,6 +256,13 @@ interface ISettler { */ function getUserSafeguard(address user) external view returns (bytes memory); + /** + * @dev Tells whether a safeguard nonce was already used by a user + * @param user Address of the user being queried + * @param nonce Safeguard nonce being queried + */ + function isUserSafeguardNonceUsed(address user, uint256 nonce) external view returns (bool); + /** * @dev Tells the hash of an intent * @param intent Intent to get the hash of @@ -290,6 +312,15 @@ interface ISettler { */ function setSafeguard(bytes memory safeguard) external; + /** + * @dev Sets a safeguard on behalf of a user based on a signature authorized by that user. The user can be + * an EOA authorizing it with its own ECDSA signature, or a smart account implementing ERC-1271. + * @param authorization Safeguard authorization signed by the user + * @param signature EIP-712 signature authorizing the safeguard, verified with ECDSA or ERC-1271. It may be + * empty for smart accounts that track approved messages on-chain. + */ + function setSafeguardWithSignature(SafeguardAuthorization memory authorization, bytes memory signature) external; + /** * @dev Executes a proposal to fulfill an intent * @param intent Intent to be fulfilled diff --git a/packages/evm/contracts/safeguards/Safeguards.sol b/packages/evm/contracts/safeguards/Safeguards.sol index 3705b2a..8d4967a 100644 --- a/packages/evm/contracts/safeguards/Safeguards.sol +++ b/packages/evm/contracts/safeguards/Safeguards.sol @@ -57,3 +57,35 @@ struct Safeguard { uint8 mode; bytes config; } + +/** + * @dev EIP-712 typed data struct representing a user's authorization to set its safeguard + * @param user User the safeguard belongs to + * @param safeguard Encoded safeguard config to be set for the user + * @param nonce Unique value chosen by the user to prevent replay attacks + * @param deadline Timestamp by which the safeguard must be set + */ +struct SafeguardAuthorization { + address user; + bytes safeguard; + uint256 nonce; + uint256 deadline; +} + +library SafeguardsHelpers { + bytes32 internal constant SAFEGUARD_AUTHORIZATION_TYPE_HASH = + keccak256('SafeguardAuthorization(address user,bytes safeguard,uint256 nonce,uint256 deadline)'); + + function hash(SafeguardAuthorization memory authorization) internal pure returns (bytes32) { + return + keccak256( + abi.encode( + SAFEGUARD_AUTHORIZATION_TYPE_HASH, + authorization.user, + keccak256(authorization.safeguard), + authorization.nonce, + authorization.deadline + ) + ); + } +} diff --git a/packages/evm/hardhat.config.ts b/packages/evm/hardhat.config.ts index 2222a3a..63f8fde 100644 --- a/packages/evm/hardhat.config.ts +++ b/packages/evm/hardhat.config.ts @@ -13,6 +13,8 @@ const config: HardhatUserConfig = { default: { version: '0.8.28', settings: { + // The IR pipeline is required to keep Settler under the EIP-170 24576-byte limit + viaIR: true, optimizer: { enabled: true, runs: 1000, diff --git a/packages/evm/test/Settler.test.ts b/packages/evm/test/Settler.test.ts index 76e914a..f81effa 100644 --- a/packages/evm/test/Settler.test.ts +++ b/packages/evm/test/Settler.test.ts @@ -16,7 +16,7 @@ import { } from '@mimicprotocol/sdk' import { HardhatEthersSigner } from '@nomicfoundation/hardhat-ethers/types' import { expect } from 'chai' -import { AbiCoder, getBytes, Wallet } from 'ethers' +import { AbiCoder, getBytes, Overrides, Wallet } from 'ethers' import { network } from 'hardhat' import { @@ -61,6 +61,7 @@ import { literal, Proposal, signProposal, + signSafeguardAuthorization, SwapOperation, SwapProposal, toAddress, @@ -440,36 +441,273 @@ describe('Settler', () => { settler = settler.connect(user) }) - context('when the user had no safeguards', () => { + const itSetsTheSafeguard = () => { it('sets the safeguard', async () => { const tx = await settler.setSafeguard(safeguard) - const currentSafeguard = await settler.getUserSafeguard(user) - expect(currentSafeguard).to.be.equal(safeguard) + expect(await settler.getUserSafeguard(user)).to.be.equal(safeguard) const events = await settler.queryFilter(settler.filters.SafeguardSet(), tx.blockNumber) expect(events).to.have.lengthOf(1) - expect(events[0].args.user).to.equal(user) + expect(events[0].args.user).to.equal(user.address) }) + } + + context('when the user had no safeguards', () => { + itSetsTheSafeguard() }) context('when the user already had safeguards', () => { - const previousSafeguard = randomHex(64) - beforeEach('set safeguard', async () => { - await settler.setSafeguard(previousSafeguard) + await settler.setSafeguard(randomHex(64)) }) - it('replaces the previous safeguard', async () => { - const tx = await settler.setSafeguard(safeguard) + itSetsTheSafeguard() + }) + }) + + describe('setSafeguardWithSignature', () => { + const safeguard = randomHex(64) + const nonce = BigInt(randomHex(8)) + let account: Account, signature: string, deadline: bigint, overrides: Overrides + + beforeEach('set sender', async () => { + // The safeguard is submitted by an account other than the user to make sure the signer is the one authorizing it + settler = settler.connect(other) + overrides = {} + }) + + const itSetsTheSafeguard = () => { + it('sets the safeguard', async () => { + const tx = await settler.setSafeguardWithSignature( + { user: account, safeguard, nonce, deadline }, + signature, + overrides + ) - const currentSafeguard = await settler.getUserSafeguard(user) - expect(currentSafeguard).to.be.equal(safeguard) - expect(currentSafeguard).to.not.be.equal(previousSafeguard) + expect(await settler.getUserSafeguard(account)).to.be.equal(safeguard) const events = await settler.queryFilter(settler.filters.SafeguardSet(), tx.blockNumber) expect(events).to.have.lengthOf(1) - expect(events[0].args.user).to.equal(user) + expect(events[0].args.user).to.equal(toAddress(account)) + }) + } + + const itConsumesTheUserNonce = () => { + it('consumes the user nonce', async () => { + await settler.setSafeguardWithSignature({ user: account, safeguard, nonce, deadline }, signature, overrides) + + expect(await settler.isUserSafeguardNonceUsed(account, nonce)).to.be.true + expect(await settler.isUserSafeguardNonceUsed(account, nonce + 1n)).to.be.false + expect(await settler.isUserSafeguardNonceUsed(other, nonce)).to.be.false + }) + } + + const itRevertsWithInvalidSignature = () => { + it('reverts', async () => { + await expect( + settler.setSafeguardWithSignature({ user: account, safeguard, nonce, deadline }, signature, overrides) + ).to.be.revertedWithCustomError(settler, 'SettlerSafeguardInvalidSignature') + }) + } + + context('when the deadline has not been reached', () => { + beforeEach('set deadline', async () => { + deadline = (await currentTimestamp()) + BigInt(120 * 10) + }) + + context('when the nonce was not used', () => { + context('when the user is an EOA', () => { + beforeEach('set account', () => { + account = user + }) + + context('when the signature was signed by the user', () => { + context('when the signature was signed for the same user', () => { + context('when the signature was signed for the same safeguard', () => { + context('when the signature was signed for the same nonce', () => { + context('when the signature was signed for the same deadline', () => { + beforeEach('sign safeguard', async () => { + signature = await signSafeguardAuthorization(settler, account, safeguard, nonce, deadline, user) + }) + + context('when the user had no safeguards', () => { + itSetsTheSafeguard() + itConsumesTheUserNonce() + }) + + context('when the user already had safeguards', () => { + beforeEach('set safeguard', async () => { + await settler.connect(user).setSafeguard(randomHex(64)) + }) + + itSetsTheSafeguard() + }) + }) + + context('when the signature was signed for another deadline', () => { + beforeEach('sign safeguard', async () => { + signature = await signSafeguardAuthorization( + settler, + account, + safeguard, + nonce, + deadline + 1n, + user + ) + }) + + itRevertsWithInvalidSignature() + }) + }) + + context('when the signature was signed for another nonce', () => { + beforeEach('sign safeguard', async () => { + signature = await signSafeguardAuthorization( + settler, + account, + safeguard, + nonce + 1n, + deadline, + user + ) + }) + + itRevertsWithInvalidSignature() + }) + }) + + context('when the signature was signed for another safeguard', () => { + beforeEach('sign safeguard', async () => { + signature = await signSafeguardAuthorization(settler, account, randomHex(64), nonce, deadline, user) + }) + + itRevertsWithInvalidSignature() + }) + }) + + context('when the signature was signed for another user', () => { + beforeEach('sign safeguard', async () => { + signature = await signSafeguardAuthorization(settler, other, safeguard, nonce, deadline, user) + }) + + itRevertsWithInvalidSignature() + }) + }) + + context('when the signature was signed by another account', () => { + beforeEach('sign safeguard', async () => { + signature = await signSafeguardAuthorization(settler, account, safeguard, nonce, deadline, other) + }) + + itRevertsWithInvalidSignature() + }) + }) + + context('when the user is an EIP-7702 delegated EOA', () => { + beforeEach('set account', () => { + account = user + }) + + beforeEach('delegate the user code', async () => { + const delegate = await ethers.deployContract('SmartAccount7702', [settler]) + const authorization = await user.authorize({ address: delegate.target }) + overrides = { authorizationList: [authorization] } + }) + + beforeEach('sign safeguard', async () => { + signature = await signSafeguardAuthorization(settler, account, safeguard, nonce, deadline, user) + }) + + afterEach('reset the user delegation', async () => { + // The delegation persists on the network, it must be cleared so the user is an EOA again + const reset = await user.authorize({ address: ZERO_ADDRESS }) + await other.sendTransaction({ to: other, authorizationList: [reset] }) + }) + + it('delegates the user code', async () => { + await settler.setSafeguardWithSignature({ user: account, safeguard, nonce, deadline }, signature, overrides) + + expect(await ethers.provider.getCode(user.address)).to.not.be.equal('0x') + }) + + itSetsTheSafeguard() + itConsumesTheUserNonce() + }) + + context('when the user is a smart account', () => { + context('when the smart account supports ERC-1271', () => { + beforeEach('deploy smart account', async () => { + account = await ethers.deployContract('SmartAccountContract', [settler, user]) + }) + + context('when the signature was signed by an account allowed by the smart account', () => { + beforeEach('sign safeguard', async () => { + signature = await signSafeguardAuthorization(settler, account, safeguard, nonce, deadline, user) + }) + + itSetsTheSafeguard() + itConsumesTheUserNonce() + }) + + context('when the signature was signed by another account', () => { + beforeEach('sign safeguard', async () => { + signature = await signSafeguardAuthorization(settler, account, safeguard, nonce, deadline, other) + }) + + itRevertsWithInvalidSignature() + }) + }) + + context('when the smart account does not support ERC-1271', () => { + beforeEach('deploy smart account', async () => { + account = await ethers.deployContract('CallMock', []) + }) + + beforeEach('sign safeguard', async () => { + signature = await signSafeguardAuthorization(settler, account, safeguard, nonce, deadline, user) + }) + + itRevertsWithInvalidSignature() + }) + }) + }) + + context('when the nonce was already used', () => { + beforeEach('set account', () => { + account = user + }) + + beforeEach('set safeguard', async () => { + signature = await signSafeguardAuthorization(settler, account, safeguard, nonce, deadline, user) + await settler.setSafeguardWithSignature({ user: account, safeguard, nonce, deadline }, signature, overrides) + }) + + it('reverts', async () => { + await expect( + settler.setSafeguardWithSignature({ user: account, safeguard, nonce, deadline }, signature, overrides) + ).to.be.revertedWithCustomError(settler, 'SettlerSafeguardNonceAlreadyUsed') + }) + }) + }) + + context('when the deadline has been reached', () => { + beforeEach('set account', () => { + account = user + }) + + beforeEach('set deadline', async () => { + deadline = await currentTimestamp() + }) + + beforeEach('sign safeguard', async () => { + signature = await signSafeguardAuthorization(settler, account, safeguard, nonce, deadline, user) + }) + + it('reverts', async () => { + await expect( + settler.setSafeguardWithSignature({ user: account, safeguard, nonce, deadline }, signature, overrides) + ).to.be.revertedWithCustomError(settler, 'SettlerSafeguardPastDeadline') }) }) }) diff --git a/packages/evm/test/helpers/safeguards.ts b/packages/evm/test/helpers/safeguards.ts index 91b0e48..017950f 100644 --- a/packages/evm/test/helpers/safeguards.ts +++ b/packages/evm/test/helpers/safeguards.ts @@ -1,10 +1,22 @@ -import { AbiCoder } from 'ethers' +import { BigNumberish, SETTLER_EIP712_DOMAIN } from '@mimicprotocol/sdk' +import { HardhatEthersSigner } from '@nomicfoundation/hardhat-ethers/types' +import { AbiCoder, Contract } from 'ethers' +import { network } from 'hardhat' import { Account, toAddress } from './addresses' import { NAry, toArray } from './arrays' /* eslint-disable no-unused-vars */ +export const SAFEGUARD_AUTHORIZATION_712_TYPE = { + SafeguardAuthorization: [ + { name: 'user', type: 'address' }, + { name: 'safeguard', type: 'bytes' }, + { name: 'nonce', type: 'uint256' }, + { name: 'deadline', type: 'uint256' }, + ], +} + export enum SafeguardConfigMode { List, Tree, @@ -101,3 +113,22 @@ export function createTreeSafeguard(groups: SafeguardGroup[], leaves: Safeguard[ ) return coder.encode(['uint8', 'bytes'], [SafeguardConfigMode.Tree, payload]) } + +export async function signSafeguardAuthorization( + settler: Contract, + user: Account, + safeguard: string, + nonce: BigNumberish, + deadline: BigNumberish, + signer: HardhatEthersSigner +): Promise { + const connection = await network.connect() + const chainId = connection.networkConfig.chainId + const domain = { ...SETTLER_EIP712_DOMAIN, chainId, verifyingContract: settler.target } + return signer.signTypedData(domain, SAFEGUARD_AUTHORIZATION_712_TYPE, { + user: toAddress(user), + safeguard, + nonce, + deadline, + }) +}