diff --git a/packages/evm/contracts/Intents.sol b/packages/evm/contracts/Intents.sol index 84aac07..e63dd86 100644 --- a/packages/evm/contracts/Intents.sol +++ b/packages/evm/contracts/Intents.sol @@ -4,14 +4,16 @@ pragma solidity ^0.8.20; /** * @dev Enum representing the operation type. - * - Swap: Swap tokens between chains or tokens. + * - Swap: Swap tokens in the same chain. * - Transfer: Transfer tokens to one or more recipients. * - Call: Execute arbitrary contract calls. + * - CrossChainSwap: Swap tokens between chains. */ enum OpType { Swap, Transfer, - Call + Call, + CrossChainSwap } /** @@ -187,7 +189,7 @@ struct SwapProposal { library IntentsHelpers { bytes32 internal constant INTENT_TYPE_HASH = keccak256( - 'Intent(address feePayer,address settler,bytes32 nonce,uint256 deadline,MaxFee[] maxFees,bytes triggerSig,uint256 minValidations,Operation[] operations)MaxFee(address token,uint256 amount)Operation(uint8 opType,address user,bytes data,OperationEvent[] events,bytes32 intentNonce,uint256 index)OperationEvent(bytes32 topic,bytes data)' + 'Intent(address feePayer,address settler,bytes32 nonce,uint256 deadline,MaxFee[] maxFees,bytes triggerSig,uint256 minValidations,Operation[] operations)MaxFee(address token,uint256 amount)Operation(uint8 opType,address user,bytes data,OperationEvent[] events)OperationEvent(bytes32 topic,bytes data)' ); bytes32 internal constant PROPOSAL_TYPE_HASH = @@ -198,9 +200,7 @@ library IntentsHelpers { bytes32 internal constant MAX_FEE_TYPE_HASH = keccak256('MaxFee(address token,uint256 amount)'); bytes32 internal constant OPERATION_TYPE_HASH = - keccak256( - 'Operation(uint8 opType,address user,bytes data,OperationEvent[] events,bytes32 intentNonce,uint256 index)' - ); + keccak256('Operation(uint8 opType,address user,bytes data,OperationEvent[] events)'); bytes32 internal constant OPERATION_EVENT_TYPE_HASH = keccak256('OperationEvent(bytes32 topic,bytes data)'); @@ -216,7 +216,7 @@ library IntentsHelpers { hash(intent.maxFees), intent.triggerSig, intent.minValidations, - hash(intent.operations, intent.nonce) + hash(intent.operations) ) ); } @@ -243,15 +243,13 @@ library IntentsHelpers { return keccak256(abi.encodePacked(hashes)); } - function hash(Operation[] memory operations, bytes32 intentNonce) internal pure returns (bytes32) { + function hash(Operation[] memory operations) internal pure returns (bytes32) { bytes32[] memory hashes = new bytes32[](operations.length); - for (uint256 i = 0; i < operations.length; i++) { - hashes[i] = hash(operations[i], intentNonce, i); - } + for (uint256 i = 0; i < operations.length; i++) hashes[i] = hash(operations[i]); return keccak256(abi.encodePacked(hashes)); } - function hash(Operation memory operation, bytes32 intentNonce, uint256 index) internal pure returns (bytes32) { + function hash(Operation memory operation) internal pure returns (bytes32) { return keccak256( abi.encode( @@ -259,9 +257,7 @@ library IntentsHelpers { operation.opType, operation.user, keccak256(operation.data), - hash(operation.events), - intentNonce, - index + hash(operation.events) ) ); } diff --git a/packages/evm/contracts/Settler.sol b/packages/evm/contracts/Settler.sol index 8d43eec..f7dd29a 100644 --- a/packages/evm/contracts/Settler.sol +++ b/packages/evm/contracts/Settler.sol @@ -40,7 +40,6 @@ contract Settler is ISettler, Ownable, ReentrancyGuard, EIP712 { using SafeERC20 for IERC20; using IntentsHelpers for Intent; using IntentsHelpers for Proposal; - using IntentsHelpers for Operation; using IntentsHelpers for Validation; using SmartAccountsHandlerHelpers for address; @@ -202,8 +201,9 @@ contract Settler is ISettler, Ownable, ReentrancyGuard, EIP712 { for (uint256 i = 0; i < intent.operations.length; i++) { uint8 opType = intent.operations[i].opType; - if (opType == uint8(OpType.Swap)) _executeSwap(intent, proposal, i); - else if (opType == uint8(OpType.Transfer)) _executeTransfer(intent, proposal, i); + if (opType == uint8(OpType.Swap) || opType == uint8(OpType.CrossChainSwap)) { + _executeSwap(intent, proposal, i); + } else if (opType == uint8(OpType.Transfer)) _executeTransfer(intent, proposal, i); else if (opType == uint8(OpType.Call)) _executeCall(intent, proposal, i); else revert SettlerUnknownOperationType(uint8(opType)); } @@ -221,7 +221,10 @@ contract Settler is ISettler, Ownable, ReentrancyGuard, EIP712 { Operation memory operation = intent.operations[index]; SwapOperation memory swapOperation = abi.decode(operation.data, (SwapOperation)); SwapProposal memory swapProposal = abi.decode(proposal.datas[index], (SwapProposal)); - _validateSwapOperation(swapOperation, swapProposal); + if (operation.opType == uint8(OpType.CrossChainSwap)) { + if (intent.operations.length > 1) revert SettlerCrossChainSwapMustBeOnlyOperation(); + _validateCrossChainSwapOperation(swapOperation, swapProposal); + } else _validateSingleChainSwapOperation(swapOperation, swapProposal); bool isSmartAccount = smartAccountsHandler.isSmartAccount(operation.user); if (swapOperation.sourceChain == block.chainid) { @@ -232,8 +235,7 @@ contract Settler is ISettler, Ownable, ReentrancyGuard, EIP712 { } uint256[] memory preBalancesOut = _getTokensOutBalance(swapOperation); - bytes32 operationHash = operation.hash(intent.nonce, index); - IExecutor(swapProposal.executor).execute(operation, operationHash, proposal.datas[index]); + IExecutor(swapProposal.executor).execute(intent, proposal, index); if (swapOperation.destinationChain == block.chainid) { uint256[] memory outputs = new uint256[](swapOperation.tokensOut.length); @@ -299,7 +301,6 @@ contract Settler is ISettler, Ownable, ReentrancyGuard, EIP712 { * @dev Validates an intent and its corresponding proposal The off-chain validators are assuring that: - The trigger signer has authorization over the intent.feePayer and each operations[i].user - - If there is a cross-chain swap operation, it is the last one * @param intent Intent to be fulfilled * @param proposal Proposal to be executed * @param signature Proposal signature @@ -370,9 +371,6 @@ contract Settler is ISettler, Ownable, ReentrancyGuard, EIP712 { * @param proposal Proposal to be executed */ function _validateSwapOperation(SwapOperation memory operation, SwapProposal memory proposal) internal view { - bool isChainInvalid = operation.sourceChain != block.chainid && operation.destinationChain != block.chainid; - if (isChainInvalid) revert SettlerInvalidChain(block.chainid); - if (proposal.amountsOut.length != operation.tokensOut.length) revert SettlerInvalidProposedAmounts(); for (uint256 i = 0; i < operation.tokensOut.length; i++) { @@ -384,11 +382,20 @@ contract Settler is ISettler, Ownable, ReentrancyGuard, EIP712 { uint256 proposedAmount = proposal.amountsOut[i]; if (proposedAmount < minAmount) revert SettlerProposedAmountLtMinAmount(i, proposedAmount, minAmount); } + } - if (operation.sourceChain != operation.destinationChain) { - bool isExecutorInvalid = !IController(controller).isExecutorAllowed(proposal.executor); - if (isExecutorInvalid) revert SettlerExecutorNotAllowed(proposal.executor); - } + /** + * @dev Validates a single-chain swap operation and its corresponding proposal + * @param operation Swap operation to be fulfilled + * @param proposal Proposal to be executed + */ + function _validateSingleChainSwapOperation(SwapOperation memory operation, SwapProposal memory proposal) + internal + view + { + if (operation.sourceChain != operation.destinationChain) revert SettlerOperationChainsMismatch(); + if (operation.sourceChain != block.chainid) revert SettlerInvalidChain(block.chainid); + _validateSwapOperation(operation, proposal); } /** @@ -420,6 +427,23 @@ contract Settler is ISettler, Ownable, ReentrancyGuard, EIP712 { if (!smartAccountsHandler.isSmartAccount(user)) revert SettlerUserNotSmartAccount(user); } + /** + * @dev Validates a cross-chain swap operation and its corresponding proposal + * @param operation Swap operation to be fulfilled + * @param proposal Proposal to be executed + */ + function _validateCrossChainSwapOperation(SwapOperation memory operation, SwapProposal memory proposal) + internal + view + { + if (operation.sourceChain == operation.destinationChain) revert SettlerOperationChainsMismatch(); + bool isChainInvalid = operation.sourceChain != block.chainid && operation.destinationChain != block.chainid; + if (isChainInvalid) revert SettlerInvalidChain(block.chainid); + _validateSwapOperation(operation, proposal); + bool isExecutorInvalid = !IController(controller).isExecutorAllowed(proposal.executor); + if (isExecutorInvalid) revert SettlerExecutorNotAllowed(proposal.executor); + } + /** * @dev Tells the contract balance for each token out of a swap operation * @param operation Swap operation containing the list of tokens out @@ -439,11 +463,10 @@ contract Settler is ISettler, Ownable, ReentrancyGuard, EIP712 { * @param intent Intent to be fulfilled */ function _shouldValidateDeadlines(Intent memory intent) internal view returns (bool) { - // Validators ensure off-chain that a cross-chain operation can only be the last operation - Operation memory finalOperation = intent.operations[intent.operations.length - 1]; - if (finalOperation.opType != uint8(OpType.Swap)) return true; - SwapOperation memory swapIntent = abi.decode(finalOperation.data, (SwapOperation)); - if (swapIntent.sourceChain == swapIntent.destinationChain) return true; + // Cross-chain operation can only be the first (and only) operation + Operation memory operation = intent.operations[0]; + if (operation.opType != uint8(OpType.CrossChainSwap)) return true; + SwapOperation memory swapIntent = abi.decode(operation.data, (SwapOperation)); return swapIntent.sourceChain == block.chainid; } @@ -484,6 +507,11 @@ contract Settler is ISettler, Ownable, ReentrancyGuard, EIP712 { * @param proposal Proposal to be executed */ function _payFees(Intent memory intent, Proposal memory proposal) internal { + // A CrossChainSwap only pays fees on destination chain and must be the only operation + if (intent.operations[0].opType == uint8(OpType.CrossChainSwap)) { + SwapOperation memory swapOperation = abi.decode(intent.operations[0].data, (SwapOperation)); + if (swapOperation.sourceChain == block.chainid) return; + } address from = intent.feePayer; address to = _msgSender(); bool isSmartAccount = smartAccountsHandler.isSmartAccount(from); diff --git a/packages/evm/contracts/interfaces/IExecutor.sol b/packages/evm/contracts/interfaces/IExecutor.sol index 31cd8b1..e93f4bb 100644 --- a/packages/evm/contracts/interfaces/IExecutor.sol +++ b/packages/evm/contracts/interfaces/IExecutor.sol @@ -10,9 +10,9 @@ import '../Intents.sol'; interface IExecutor { /** * @dev Executes an operation proposal - * @param operation Operation to be executed - * @param operationHash unique hash of the operation - * @param proposalData data of the proposal to be executed to fulfill the operation + * @param intent Intent that contains swap operation to be executed + * @param proposal Proposal with swap data to be executed + * @param index Position where the swap proposal data and operation are located */ - function execute(Operation memory operation, bytes32 operationHash, bytes memory proposalData) external; + function execute(Intent memory intent, Proposal memory proposal, uint256 index) external; } diff --git a/packages/evm/contracts/interfaces/ISettler.sol b/packages/evm/contracts/interfaces/ISettler.sol index ec3c0cb..efc46de 100644 --- a/packages/evm/contracts/interfaces/ISettler.sol +++ b/packages/evm/contracts/interfaces/ISettler.sol @@ -145,9 +145,14 @@ interface ISettler { error SettlerTooManySafeguards(uint256 lengthRequested); /** - * @dev The chain of an operation does not match the rest of the intent's operations + * @dev The chains of a swap operation do not match the swap type (single or cross chain) */ - error SettlerOperationChainMismatch(uint256 expected, uint256 actual); + error SettlerOperationChainsMismatch(); + + /** + * @dev A CrossChainSwap operation must be the only operation in the intent + */ + error SettlerCrossChainSwapMustBeOnlyOperation(); /** * @dev The new smart accounts handler is zero diff --git a/packages/evm/contracts/test/executors/EmptyExecutorMock.sol b/packages/evm/contracts/test/executors/EmptyExecutorMock.sol index 7a787c4..7dce8c2 100644 --- a/packages/evm/contracts/test/executors/EmptyExecutorMock.sol +++ b/packages/evm/contracts/test/executors/EmptyExecutorMock.sol @@ -7,7 +7,7 @@ import '../../interfaces/IExecutor.sol'; contract EmptyExecutorMock is IExecutor { event Executed(); - function execute(Operation memory, bytes32, bytes memory) external override { + function execute(Intent memory, Proposal memory, uint256) external override { emit Executed(); } } diff --git a/packages/evm/contracts/test/executors/MintExecutorMock.sol b/packages/evm/contracts/test/executors/MintExecutorMock.sol index 270e0e1..1333403 100644 --- a/packages/evm/contracts/test/executors/MintExecutorMock.sol +++ b/packages/evm/contracts/test/executors/MintExecutorMock.sol @@ -10,10 +10,14 @@ import '../../interfaces/IExecutor.sol'; contract MintExecutorMock is IExecutor { event Minted(); - function execute(Operation memory operation, bytes32, bytes memory proposalData) external override { - require(operation.opType == uint8(OpType.Swap), 'Invalid operation type'); - - SwapProposal memory swapProposal = abi.decode(proposalData, (SwapProposal)); + function execute(Intent memory intent, Proposal memory proposal, uint256 index) external override { + Operation memory operation = intent.operations[index]; + require( + operation.opType == uint8(OpType.Swap) || operation.opType == uint8(OpType.CrossChainSwap), + 'Invalid operation type' + ); + + SwapProposal memory swapProposal = abi.decode(proposal.datas[index], (SwapProposal)); (address[] memory tokens, uint256[] memory amounts) = abi.decode(swapProposal.data, (address[], uint256[])); require(tokens.length == amounts.length, 'Invalid inputs'); diff --git a/packages/evm/contracts/test/executors/ReentrantExecutorMock.sol b/packages/evm/contracts/test/executors/ReentrantExecutorMock.sol index 7eb7a03..87a97f8 100644 --- a/packages/evm/contracts/test/executors/ReentrantExecutorMock.sol +++ b/packages/evm/contracts/test/executors/ReentrantExecutorMock.sol @@ -13,9 +13,7 @@ contract ReentrantExecutorMock is IExecutor { settler = _settler; } - function execute(Operation memory, bytes32, bytes memory) external override { - Intent memory intent; - Proposal memory proposal; + function execute(Intent memory intent, Proposal memory proposal, uint256) external override { ISettler(settler).execute(intent, proposal, new bytes(0)); } } diff --git a/packages/evm/contracts/test/executors/TransferExecutorMock.sol b/packages/evm/contracts/test/executors/TransferExecutorMock.sol index 86b0762..fb096ba 100644 --- a/packages/evm/contracts/test/executors/TransferExecutorMock.sol +++ b/packages/evm/contracts/test/executors/TransferExecutorMock.sol @@ -14,10 +14,14 @@ contract TransferExecutorMock is IExecutor { // solhint-disable-previous-line no-empty-blocks } - function execute(Operation memory operation, bytes32, bytes memory proposalData) external override { - require(operation.opType == uint8(OpType.Swap), 'Invalid operation type'); - - SwapProposal memory swapProposal = abi.decode(proposalData, (SwapProposal)); + function execute(Intent memory intent, Proposal memory proposal, uint256 index) external override { + Operation memory operation = intent.operations[index]; + require( + operation.opType == uint8(OpType.Swap) || operation.opType == uint8(OpType.CrossChainSwap), + 'Invalid operation type' + ); + + SwapProposal memory swapProposal = abi.decode(proposal.datas[index], (SwapProposal)); (address[] memory tokens, uint256[] memory amounts) = abi.decode(swapProposal.data, (address[], uint256[])); require(tokens.length == amounts.length, 'Invalid inputs'); diff --git a/packages/evm/test/Settler.test.ts b/packages/evm/test/Settler.test.ts index 6c834b7..2c1fd3b 100644 --- a/packages/evm/test/Settler.test.ts +++ b/packages/evm/test/Settler.test.ts @@ -4,6 +4,7 @@ import { MAX_UINT256, NATIVE_TOKEN_ADDRESS, ONES_BYTES32, + Operation, OpType, randomEvmAddress, randomHex, @@ -35,6 +36,8 @@ import { createCallIntent, createCallOperation, createCallProposal, + createCrossChainSwapIntent, + createCrossChainSwapOperation, createIntent, createProposal, createSwapIntent, @@ -414,7 +417,7 @@ describe('Settler', () => { context('when the swap is cross-chain', () => { context('when executing on the source chain', () => { beforeEach('set intent operation', async () => { - const operation = createSwapOperation({ + const operation = createCrossChainSwapOperation({ user: intentParams.feePayer, sourceChain: 31337, destinationChain: 1, @@ -429,7 +432,7 @@ describe('Settler', () => { context('when executing on the destination chain', () => { beforeEach('set intent operation', async () => { - const operation = createSwapOperation({ + const operation = createCrossChainSwapOperation({ user: intentParams.feePayer, sourceChain: 1, destinationChain: 31337, @@ -566,67 +569,553 @@ describe('Settler', () => { await controller.connect(admin).setAllowedProposalSigners([admin], [true]) }) - context('when the operations have all the same chain', () => { - context('for swap operations', () => { - const swapOperationParams: Partial = {} - const swapProposalParams: Partial = {} - let tokenIn: TokenMock, tokenOut: TokenMock, executor: MintExecutorMock - - const amountIn = fp(1) - const proposedAmountOut = amountIn - 1n - const minAmount = proposedAmountOut - 1n - - beforeEach('set tokens', async () => { - tokenIn = await ethers.deployContract('TokenMock', ['IN', 18]) - tokenOut = await ethers.deployContract('TokenMock', ['OUT', 18]) - swapOperationParams.tokensIn = [{ token: tokenIn, amount: amountIn }] - swapOperationParams.tokensOut = [ - { token: tokenOut, recipient: other, minAmount }, - ] + context('for single chain swap operations', () => { + const swapOperationParams: Partial = {} + const swapProposalParams: Partial = {} + let tokenIn: TokenMock, tokenOut: TokenMock, executor: MintExecutorMock + + const amountIn = fp(1) + const proposedAmountOut = amountIn - 1n + const minAmount = proposedAmountOut - 1n + + beforeEach('set tokens', async () => { + tokenIn = await ethers.deployContract('TokenMock', ['IN', 18]) + tokenOut = await ethers.deployContract('TokenMock', ['OUT', 18]) + swapOperationParams.tokensIn = [{ token: tokenIn, amount: amountIn }] + swapOperationParams.tokensOut = [ + { token: tokenOut, recipient: other, minAmount }, + ] + }) + + beforeEach('set executor', async () => { + executor = await ethers.deployContract('MintExecutorMock') + swapProposalParams.executor = executor + }) + + beforeEach('mint and approve tokens', async () => { + await tokenIn.mint(user, amountIn) + await tokenIn.connect(user).approve(settler, amountIn) + swapOperationParams.user = user + }) + + const itReverts = (reason: string) => { + it('reverts', async () => { + const intent = createSwapIntent(intentParams, swapOperationParams) + await addValidations(settler, intent, [validator1, validator2]) + const proposal = createSwapProposal({ + ...proposalParams, + ...swapProposalParams, + }) + const signature = await signProposal( + settler, + intent, + solver, + proposal, + admin + ) + + await expect( + settler.execute(intent, proposal, signature) + ).to.be.revertedWithCustomError(settler, reason) + }) + } + + const itValidatesIntentsProperly = ( + sourceChain: number, + destinationChain: number + ) => { + beforeEach('set source and destination chains', () => { + swapOperationParams.sourceChain = sourceChain + swapOperationParams.destinationChain = destinationChain + }) + + context('when the proposed amounts length is correct', () => { + beforeEach('set proposed amounts', () => { + swapProposalParams.amountsOut = [proposedAmountOut] + }) + + context('when no recipient is the settler', () => { + beforeEach('set recipient', () => { + toArray(swapOperationParams.tokensOut).forEach((tokenOut) => { + tokenOut.recipient = other + }) + }) + + context('when the proposal amount is greater than the min amount', () => { + beforeEach('set proposal amount', () => { + swapProposalParams.amountsOut = [minAmount + 1n] + }) + + const itExecutesTheProposalSuccessfully = () => { + const itExecutesSuccessfully = () => { + it('executes successfully', async () => { + const intent = createSwapIntent(intentParams, swapOperationParams) + + await addValidations(settler, intent, [validator1, validator2]) + const proposal = createSwapProposal({ + ...proposalParams, + ...swapProposalParams, + }) + const signature = await signProposal( + settler, + intent, + solver, + proposal, + admin + ) + + const tx = await settler.execute(intent, proposal, signature) + + const executorEvents = await executor.queryFilter( + executor.filters.Minted(), + tx.blockNumber + ) + expect(executorEvents).to.have.lengthOf(1) + + const settlerEvents = await settler.queryFilter( + settler.filters.ProposalExecuted(), + tx.blockNumber + ) + expect(settlerEvents).to.have.lengthOf(1) + + const proposalHash = await settler.getProposalHash( + proposal, + intent, + solver + ) + expect(settlerEvents[0].args.proposal).to.be.equal(proposalHash) + }) + } + + context( + 'when the amount out is greater than the proposal amount', + () => { + const amountOut = proposedAmountOut + 1n + + beforeEach('set swap proposal data', async () => { + swapProposalParams.executorData = + AbiCoder.defaultAbiCoder().encode( + ['address[]', 'uint256[]'], + [[tokenOut.target], [amountOut]] + ) + }) + + itExecutesSuccessfully() + } + ) + + context( + 'when the amount out is lower than the proposal amount', + () => { + const amountOut = proposedAmountOut - 1n + + beforeEach('set swap proposal data', async () => { + swapProposalParams.executorData = + AbiCoder.defaultAbiCoder().encode( + ['address[]', 'uint256[]'], + [[tokenOut.target], [amountOut]] + ) + }) + + if (destinationChain == 31337) + itReverts('SettlerAmountOutLtProposed') + else itExecutesSuccessfully() + } + ) + } + + context('when the executor is allowed', () => { + beforeEach('allow executor', async () => { + await controller + .connect(admin) + .setAllowedExecutors([executor], [true]) + }) + + itExecutesTheProposalSuccessfully() + }) + + context('when the executor is not allowed', () => { + beforeEach('disallow executor', async () => { + await controller + .connect(admin) + .setAllowedExecutors([executor], [false]) + }) + + if (sourceChain == destinationChain) + itExecutesTheProposalSuccessfully() + else itReverts('SettlerExecutorNotAllowed') + }) + }) + + context('when the proposal amount is lower than the min amount', () => { + beforeEach('set proposal amount', () => { + swapProposalParams.amountsOut = [minAmount - 1n] + }) + + itReverts('SettlerProposedAmountLtMinAmount') + }) + }) + + context('when a recipient is the settler', () => { + beforeEach('set recipient', () => { + toArray(swapOperationParams.tokensOut).forEach((tokenOut) => { + tokenOut.recipient = settler + }) + }) + + itReverts('SettlerInvalidRecipient') + }) }) - beforeEach('set executor', async () => { - executor = await ethers.deployContract('MintExecutorMock') - swapProposalParams.executor = executor + context('when the proposed amounts length is not correct', () => { + beforeEach('set proposed amounts', () => { + swapProposalParams.amountsOut = [minAmount, minAmount] + }) + + itReverts('SettlerInvalidProposedAmounts') }) + } - beforeEach('mint and approve tokens', async () => { - await tokenIn.mint(user, amountIn) - await tokenIn.connect(user).approve(settler, amountIn) - swapOperationParams.user = user + context('when both chains are equal', () => { + context('when chains are current chain', () => { + itValidatesIntentsProperly(31337, 31337) }) - const itReverts = (reason: string) => { - it('reverts', async () => { - const intent = createSwapIntent(intentParams, swapOperationParams) - await addValidations(settler, intent, [validator1, validator2]) - const proposal = createSwapProposal({ - ...proposalParams, - ...swapProposalParams, + context('when chains are not current chain', () => { + beforeEach('set chains', () => { + swapOperationParams.sourceChain = 1 + swapOperationParams.destinationChain = 1 + }) + + itReverts('SettlerInvalidChain') + }) + }) + + context('when both chains are different', () => { + beforeEach('set chains', () => { + swapOperationParams.sourceChain = 31337 + swapOperationParams.destinationChain = 1 + }) + + itReverts('SettlerOperationChainsMismatch') + }) + }) + + context('for transfer operations', () => { + const transferOperationParams: Partial = {} + const transferProposalParams: Partial = {} + let token: TokenMock + + const amount = fp(1) + + beforeEach('set token', async () => { + token = await ethers.deployContract('TokenMock', ['TKN', 18]) + }) + + beforeEach('set intent params', async () => { + transferOperationParams.transfers = [{ token, amount, recipient: other }] + }) + + beforeEach('mint and approve tokens', async () => { + await token.mint(user, amount) + await token.connect(user).approve(settler, amount) + transferOperationParams.user = user + }) + + const itReverts = (reason: string) => { + it('reverts', async () => { + const intent = createTransferIntent(intentParams, transferOperationParams) + await addValidations(settler, intent, [validator1, validator2]) + const proposal = createTransferProposal({ + ...proposalParams, + ...transferProposalParams, + }) + const signature = await signProposal( + settler, + intent, + solver, + proposal, + admin + ) + + await expect( + settler.execute(intent, proposal, signature) + ).to.be.revertedWithCustomError(settler, reason) + }) + } + + context('when the chain is the current chain', () => { + beforeEach('set chain', () => { + transferOperationParams.chainId = 31337 + }) + + context('when the proposal has some data', () => { + beforeEach('set proposal data', () => { + proposalParams.datas = ['0xab'] + }) + + itReverts('SettlerProposalDataNotEmpty') + }) + + context('when the proposal has no data', () => { + beforeEach('set proposal data', () => { + proposalParams.datas = ['0x'] + }) + + context('when the recipient is not the settler', () => { + beforeEach('set recipient', () => { + toArray(transferOperationParams.transfers).forEach((transfer) => { + transfer.recipient = other + }) }) - const signature = await signProposal( - settler, - intent, - solver, - proposal, - admin - ) - await expect( - settler.execute(intent, proposal, signature) - ).to.be.revertedWithCustomError(settler, reason) + it('executes successfully', async () => { + const intent = createTransferIntent( + intentParams, + transferOperationParams + ) + await addValidations(settler, intent, [validator1, validator2]) + const proposal = createTransferProposal({ + ...proposalParams, + ...transferProposalParams, + }) + const signature = await signProposal( + settler, + intent, + solver, + proposal, + admin + ) + + const tx = await settler.execute(intent, proposal, signature) + + const settlerEvents = await settler.queryFilter( + settler.filters.ProposalExecuted(), + tx.blockNumber + ) + expect(settlerEvents).to.have.lengthOf(1) + + const proposalHash = await settler.getProposalHash( + proposal, + intent, + solver + ) + expect(settlerEvents[0].args.proposal).to.be.equal(proposalHash) + }) }) - } - - const itValidatesIntentsProperly = ( - sourceChain: number, - destinationChain: number - ) => { - beforeEach('set source and destination chains', () => { - swapOperationParams.sourceChain = sourceChain - swapOperationParams.destinationChain = destinationChain + + context('when a recipient is the settler', () => { + beforeEach('set recipient', () => { + toArray(transferOperationParams.transfers).forEach((transfer) => { + transfer.recipient = settler + }) + }) + + itReverts('SettlerInvalidRecipient') + }) + }) + }) + + context('when the chain is not the current chain', () => { + beforeEach('set chain', () => { + transferOperationParams.chainId = 1 + }) + + itReverts('SettlerInvalidChain') + }) + }) + + context('for call operations', () => { + const callOperationParams: Partial = {} + const callProposalParams: Partial = {} + let token: TokenMock + + beforeEach('set token', async () => { + token = await ethers.deployContract('TokenMock', ['TKN', 18]) + }) + + beforeEach('set intent params', async () => { + const target = await ethers.deployContract('CallMock') + const data = target.interface.encodeFunctionData('call') + + callOperationParams.calls = [{ target, data, value: 0 }] + }) + + const itReverts = (reason: string) => { + it('reverts', async () => { + const intent = createCallIntent(intentParams, callOperationParams) + await addValidations(settler, intent, [validator1, validator2]) + const proposal = createCallProposal({ + ...proposalParams, + ...callProposalParams, + }) + const signature = await signProposal( + settler, + intent, + solver, + proposal, + admin + ) + + await expect( + settler.execute(intent, proposal, signature) + ).to.be.revertedWithCustomError(settler, reason) + }) + } + + context('when the chain is the current chain', () => { + beforeEach('set chain', () => { + callOperationParams.chainId = 31337 + }) + + context('when the proposal has some data', () => { + beforeEach('set proposal data', () => { + proposalParams.datas = ['0xab'] + }) + + itReverts('SettlerProposalDataNotEmpty') + }) + + context('when no data is given', () => { + beforeEach('set proposal data', () => { + proposalParams.datas = ['0x'] + }) + + context('when the user is a smart account', () => { + beforeEach('set intent user', async () => { + const smartAccountUser = await ethers.deployContract( + 'SmartAccountContract', + [settler, owner] + ) + intentParams.feePayer = smartAccountUser + callOperationParams.user = smartAccountUser + await feeToken.mint(intentParams.feePayer, feeAmount) + }) + + it('executes successfully', async () => { + const intent = createCallIntent(intentParams, callOperationParams) + await addValidations(settler, intent, [validator1, validator2]) + const proposal = createCallProposal({ + ...proposalParams, + ...callProposalParams, + }) + const signature = await signProposal( + settler, + intent, + solver, + proposal, + admin + ) + + const tx = await settler.execute(intent, proposal, signature) + + const settlerEvents = await settler.queryFilter( + settler.filters.ProposalExecuted(), + tx.blockNumber + ) + expect(settlerEvents).to.have.lengthOf(1) + + const proposalHash = await settler.getProposalHash( + proposal, + intent, + solver + ) + expect(settlerEvents[0].args.proposal).to.be.equal(proposalHash) + }) }) + context('when the user is not a smart account', () => { + context('when the user is an EOA', () => { + beforeEach('set intent user', async () => { + intentParams.feePayer = other + callOperationParams.user = other + }) + + itReverts('SettlerUserNotSmartAccount') + }) + + context('when the user is another contract', () => { + beforeEach('set intent user', async () => { + intentParams.feePayer = token + callOperationParams.user = token + }) + + itReverts('SettlerUserNotSmartAccount') + }) + }) + }) + }) + + context('when the chain is not the current chain', () => { + beforeEach('set chain', () => { + callOperationParams.chainId = 1 + }) + + itReverts('SettlerInvalidChain') + }) + }) + + context('for cross chain swap operations', () => { + const swapOperationParams: Partial = {} + const swapProposalParams: Partial = {} + let tokenIn: TokenMock, tokenOut: TokenMock, executor: MintExecutorMock + + const amountIn = fp(1) + const proposedAmountOut = amountIn - 1n + const minAmount = proposedAmountOut - 1n + + beforeEach('set tokens', async () => { + tokenIn = await ethers.deployContract('TokenMock', ['IN', 18]) + tokenOut = await ethers.deployContract('TokenMock', ['OUT', 18]) + swapOperationParams.tokensIn = [{ token: tokenIn, amount: amountIn }] + swapOperationParams.tokensOut = [ + { token: tokenOut, recipient: other, minAmount }, + ] + }) + + beforeEach('set executor', async () => { + executor = await ethers.deployContract('MintExecutorMock') + swapProposalParams.executor = executor + }) + + beforeEach('mint and approve tokens', async () => { + await tokenIn.mint(user, amountIn) + await tokenIn.connect(user).approve(settler, amountIn) + swapOperationParams.user = user + }) + + const itReverts = (reason: string) => { + it('reverts', async () => { + const intent = createCrossChainSwapIntent(intentParams, swapOperationParams) + await addValidations(settler, intent, [validator1, validator2]) + const proposal = createSwapProposal({ + ...proposalParams, + ...swapProposalParams, + }) + const signature = await signProposal( + settler, + intent, + solver, + proposal, + admin + ) + + await expect( + settler.execute(intent, proposal, signature) + ).to.be.revertedWithCustomError(settler, reason) + }) + } + + const itValidatesIntentsProperly = ( + sourceChain: number, + destinationChain: number + ) => { + beforeEach('set source and destination chains', () => { + swapOperationParams.sourceChain = sourceChain + swapOperationParams.destinationChain = destinationChain + }) + + context('when there is only one cross chain swap', () => { context('when the proposed amounts length is correct', () => { beforeEach('set proposed amounts', () => { swapProposalParams.amountsOut = [proposedAmountOut] @@ -649,7 +1138,7 @@ describe('Settler', () => { const itExecutesTheProposalSuccessfully = () => { const itExecutesSuccessfully = () => { it('executes successfully', async () => { - const intent = createSwapIntent( + const intent = createCrossChainSwapIntent( intentParams, swapOperationParams ) @@ -783,75 +1272,27 @@ describe('Settler', () => { itReverts('SettlerInvalidProposedAmounts') }) - } - - context('when the source chain is the current chain', () => { - const sourceChain = 31337 - - context('when the destination chain is the current chain', () => { - const destinationChain = 31337 - - itValidatesIntentsProperly(sourceChain, destinationChain) - }) - - context('when the destination chain is not the current chain', () => { - const destinationChain = 1 - - itValidatesIntentsProperly(sourceChain, destinationChain) - }) }) - context('when the source chain is not the current chain', () => { - const sourceChain = 1 - - context('when the destination chain is the current chain', () => { - const destinationChain = 31337 + context('when there is more than one operation', () => { + let extraOperation: Operation - itValidatesIntentsProperly(sourceChain, destinationChain) + beforeEach('set operation', () => { + extraOperation = createTransferOperation() }) - context('when the destination chain is not the current chain', () => { - const destinationChain = 1 - - beforeEach('set source and destination chains', () => { - swapOperationParams.sourceChain = sourceChain - swapOperationParams.destinationChain = destinationChain - }) - - itReverts('SettlerInvalidChain') - }) - }) - }) - - context('for transfer operations', () => { - const transferOperationParams: Partial = {} - const transferProposalParams: Partial = {} - let token: TokenMock - - const amount = fp(1) - - beforeEach('set token', async () => { - token = await ethers.deployContract('TokenMock', ['TKN', 18]) - }) - - beforeEach('set intent params', async () => { - transferOperationParams.transfers = [{ token, amount, recipient: other }] - }) - - beforeEach('mint and approve tokens', async () => { - await token.mint(user, amount) - await token.connect(user).approve(settler, amount) - transferOperationParams.user = user - }) - - const itReverts = (reason: string) => { it('reverts', async () => { - const intent = createTransferIntent(intentParams, transferOperationParams) + const intent = createCrossChainSwapIntent( + intentParams, + swapOperationParams + ) + intent.operations.push(extraOperation) await addValidations(settler, intent, [validator1, validator2]) - const proposal = createTransferProposal({ + const proposal = createSwapProposal({ ...proposalParams, - ...transferProposalParams, + ...swapProposalParams, }) + proposal.datas.push('0x') const signature = await signProposal( settler, intent, @@ -862,234 +1303,31 @@ describe('Settler', () => { await expect( settler.execute(intent, proposal, signature) - ).to.be.revertedWithCustomError(settler, reason) - }) - } - - context('when the chain is the current chain', () => { - beforeEach('set chain', () => { - transferOperationParams.chainId = 31337 - }) - - context('when the proposal has some data', () => { - beforeEach('set proposal data', () => { - proposalParams.datas = ['0xab'] - }) - - itReverts('SettlerProposalDataNotEmpty') - }) - - context('when the proposal has no data', () => { - beforeEach('set proposal data', () => { - proposalParams.datas = ['0x'] - }) - - context('when the recipient is not the settler', () => { - beforeEach('set recipient', () => { - toArray(transferOperationParams.transfers).forEach((transfer) => { - transfer.recipient = other - }) - }) - - it('executes successfully', async () => { - const intent = createTransferIntent( - intentParams, - transferOperationParams - ) - await addValidations(settler, intent, [validator1, validator2]) - const proposal = createTransferProposal({ - ...proposalParams, - ...transferProposalParams, - }) - const signature = await signProposal( - settler, - intent, - solver, - proposal, - admin - ) - - const tx = await settler.execute(intent, proposal, signature) - - const settlerEvents = await settler.queryFilter( - settler.filters.ProposalExecuted(), - tx.blockNumber - ) - expect(settlerEvents).to.have.lengthOf(1) - - const proposalHash = await settler.getProposalHash( - proposal, - intent, - solver - ) - expect(settlerEvents[0].args.proposal).to.be.equal(proposalHash) - }) - }) - - context('when a recipient is the settler', () => { - beforeEach('set recipient', () => { - toArray(transferOperationParams.transfers).forEach((transfer) => { - transfer.recipient = settler - }) - }) - - itReverts('SettlerInvalidRecipient') - }) - }) - }) - - context('when the chain is not the current chain', () => { - beforeEach('set chain', () => { - transferOperationParams.chainId = 1 - }) - - itReverts('SettlerInvalidChain') - }) - }) - - context('for call operations', () => { - const callOperationParams: Partial = {} - const callProposalParams: Partial = {} - let token: TokenMock - - beforeEach('set token', async () => { - token = await ethers.deployContract('TokenMock', ['TKN', 18]) - }) - - beforeEach('set intent params', async () => { - const target = await ethers.deployContract('CallMock') - const data = target.interface.encodeFunctionData('call') - - callOperationParams.calls = [{ target, data, value: 0 }] - }) - - const itReverts = (reason: string) => { - it('reverts', async () => { - const intent = createCallIntent(intentParams, callOperationParams) - await addValidations(settler, intent, [validator1, validator2]) - const proposal = createCallProposal({ - ...proposalParams, - ...callProposalParams, - }) - const signature = await signProposal( + ).to.be.revertedWithCustomError( settler, - intent, - solver, - proposal, - admin + 'SettlerCrossChainSwapMustBeOnlyOperation' ) - - await expect( - settler.execute(intent, proposal, signature) - ).to.be.revertedWithCustomError(settler, reason) - }) - } - - context('when the chain is the current chain', () => { - beforeEach('set chain', () => { - callOperationParams.chainId = 31337 - }) - - context('when the proposal has some data', () => { - beforeEach('set proposal data', () => { - proposalParams.datas = ['0xab'] - }) - - itReverts('SettlerProposalDataNotEmpty') - }) - - context('when no data is given', () => { - beforeEach('set proposal data', () => { - proposalParams.datas = ['0x'] - }) - - context('when the user is a smart account', () => { - beforeEach('set intent user', async () => { - const smartAccountUser = await ethers.deployContract( - 'SmartAccountContract', - [settler, owner] - ) - intentParams.feePayer = smartAccountUser - callOperationParams.user = smartAccountUser - await feeToken.mint(intentParams.feePayer, feeAmount) - }) - - it('executes successfully', async () => { - const intent = createCallIntent(intentParams, callOperationParams) - await addValidations(settler, intent, [validator1, validator2]) - const proposal = createCallProposal({ - ...proposalParams, - ...callProposalParams, - }) - const signature = await signProposal( - settler, - intent, - solver, - proposal, - admin - ) - - const tx = await settler.execute(intent, proposal, signature) - - const settlerEvents = await settler.queryFilter( - settler.filters.ProposalExecuted(), - tx.blockNumber - ) - expect(settlerEvents).to.have.lengthOf(1) - - const proposalHash = await settler.getProposalHash( - proposal, - intent, - solver - ) - expect(settlerEvents[0].args.proposal).to.be.equal(proposalHash) - }) - }) - - context('when the user is not a smart account', () => { - context('when the user is an EOA', () => { - beforeEach('set intent user', async () => { - intentParams.feePayer = other - callOperationParams.user = other - }) - - itReverts('SettlerUserNotSmartAccount') - }) - - context('when the user is another contract', () => { - beforeEach('set intent user', async () => { - intentParams.feePayer = token - callOperationParams.user = token - }) - - itReverts('SettlerUserNotSmartAccount') - }) - }) }) }) + } - context('when the chain is not the current chain', () => { - beforeEach('set chain', () => { - callOperationParams.chainId = 1 - }) + context('when both chains are different', () => { + context('when the source chain is the current chain', () => { + itValidatesIntentsProperly(31337, 1) + }) - itReverts('SettlerInvalidChain') + context('when the destination chain is the current chain', () => { + itValidatesIntentsProperly(1, 31337) }) }) - }) - context('when the operations have mismatched chains', () => { - it.skip('reverts', async () => { - const op1 = createTransferOperation({ chainId: 31337 }) - const op2 = createSwapOperation({ sourceChain: 1, destinationChain: 31337 }) - const intent = createIntent({ ...intentParams, operations: [op1, op2] }) - await addValidations(settler, intent, [validator1, validator2]) - const proposal = createProposal({ ...proposalParams, datas: ['0x', '0x'] }) - const signature = await signProposal(settler, intent, solver, proposal, admin) - - await expect( - settler.execute(intent, proposal, signature) - ).to.be.revertedWithCustomError(settler, 'SettlerOperationChainMismatch') + context('when both chains are equal', () => { + beforeEach('set chains', () => { + swapOperationParams.sourceChain = 1 + swapOperationParams.destinationChain = 1 + }) + + itReverts('SettlerOperationChainsMismatch') }) }) }) @@ -1216,7 +1454,8 @@ describe('Settler', () => { }) context('when the proposal datas length does not match the intent operations length', () => { - beforeEach('set datas', () => { + beforeEach('set data', () => { + intentParams.operations = [createTransferOperation()] proposalParams.datas = [randomHex(32), randomHex(32)] }) @@ -1743,7 +1982,7 @@ describe('Settler', () => { }) beforeEach('create intent', async () => { - intent = createSwapIntent( + intent = createCrossChainSwapIntent( { settler, feePayer: user, @@ -1789,7 +2028,7 @@ describe('Settler', () => { const itExecutesTheIntent = () => { beforeEach('create intent', async () => { - intent = createSwapIntent( + intent = createCrossChainSwapIntent( { settler, feePayer: user, @@ -1876,7 +2115,7 @@ describe('Settler', () => { }) beforeEach('create intent', async () => { - intent = createSwapIntent( + intent = createCrossChainSwapIntent( { settler, feePayer: user, @@ -1947,7 +2186,7 @@ describe('Settler', () => { const itExecutesTheIntent = () => { beforeEach('create intent', async () => { - intent = createSwapIntent( + intent = createCrossChainSwapIntent( { settler, feePayer: user, @@ -2677,6 +2916,7 @@ describe('Settler', () => { beforeEach('create intent', async () => { const callOperation = createCallOperation({ user: smartAccount, + chainId, calls: [{ target: target, data, value: callValue }], events: [{ topic: randomHex(32), data: randomHex(120) }], }) diff --git a/packages/evm/test/helpers/intents/swap.ts b/packages/evm/test/helpers/intents/swap.ts index 01187a9..d0fe74d 100644 --- a/packages/evm/test/helpers/intents/swap.ts +++ b/packages/evm/test/helpers/intents/swap.ts @@ -29,6 +29,16 @@ export function createSwapIntent(intentParams?: Partial, operationParams return intent } +export function createCrossChainSwapIntent( + intentParams?: Partial, + operationParams?: Partial +): Intent { + const intent = createIntent({ ...intentParams }) + const operation = createCrossChainSwapOperation({ ...operationParams }) + intent.operations = [operation] + return intent +} + export function createSwapOperation(params?: Partial): Operation { const operation = createOperation({ ...params, opType: OpType.Swap }) const swapOperation = { ...getDefaults(), ...params, ...operation } as SwapOperation @@ -36,6 +46,13 @@ export function createSwapOperation(params?: Partial): Operation return operation } +export function createCrossChainSwapOperation(params?: Partial): Operation { + const operation = createOperation({ ...params, opType: OpType.CrossChainSwap }) + const swapOperation = { ...getCrossChainDefaults(), ...params, ...operation } as SwapOperation + operation.data = encodeSwapOperation(toSwapOperationData(swapOperation)) + return operation +} + function toSwapOperationData(operation: SwapOperation): SwapOperationData { return { sourceChain: operation.sourceChain, @@ -60,3 +77,10 @@ function getDefaults(): Partial { tokensOut: [], } } + +function getCrossChainDefaults(): Partial { + return { + ...getDefaults(), + destinationChain: 1, + } +}