diff --git a/.gitmodules b/.gitmodules index bd60603..831c842 100644 --- a/.gitmodules +++ b/.gitmodules @@ -7,12 +7,16 @@ [submodule "lib/forge-gas-snapshot"] path = lib/forge-gas-snapshot url = https://github.com/marktoda/forge-gas-snapshot -[submodule "lib/the-compact"] - path = lib/the-compact - url = https://github.com/uniswap/the-compact [submodule "lib/openzeppelin-contracts"] path = lib/openzeppelin-contracts url = https://github.com/OpenZeppelin/openzeppelin-contracts [submodule "lib/solady"] path = lib/solady url = https://github.com/vectorized/solady +[submodule "lib/the-compact"] + path = lib/the-compact + url = https://github.com/Uniswap/the-compact + branch = v1 +[submodule "lib/tribunal"] + path = lib/tribunal + url = https://github.com/Uniswap/tribunal diff --git a/foundry.toml b/foundry.toml index f6597d1..720c746 100644 --- a/foundry.toml +++ b/foundry.toml @@ -18,6 +18,7 @@ remappings = [ "@openzeppelin/contracts=lib/openzeppelin-contracts/contracts", "@openzeppelin/contracts-upgradeable=lib/openzeppelin-contracts-upgradeable/contracts", "@uniswap/the-compact=lib/the-compact/src", + "@uniswap/tribunal=lib/tribunal/src", "@solady=lib/solady/src", ] diff --git a/lib/forge-chronicles b/lib/forge-chronicles index c2be7a4..d1fb566 160000 --- a/lib/forge-chronicles +++ b/lib/forge-chronicles @@ -1 +1 @@ -Subproject commit c2be7a462d87b8e3a72f7e2b739cdf12807a71e4 +Subproject commit d1fb566915f23a01f08747264c56f8925f15751a diff --git a/lib/forge-gas-snapshot b/lib/forge-gas-snapshot index 9161f7c..cf34ad1 160000 --- a/lib/forge-gas-snapshot +++ b/lib/forge-gas-snapshot @@ -1 +1 @@ -Subproject commit 9161f7c0b6c6788a89081e2b3b9c67592b71e689 +Subproject commit cf34ad1ed0a1f323e77557b9bce420f3385f7400 diff --git a/lib/forge-std b/lib/forge-std index 5475f85..c7be2a3 160000 --- a/lib/forge-std +++ b/lib/forge-std @@ -1 +1 @@ -Subproject commit 5475f852e3f530d7e25dfb4596aa1f9baa8ffdfc +Subproject commit c7be2a3481f9e51230880bb0949072c7e3a4da82 diff --git a/lib/openzeppelin-contracts b/lib/openzeppelin-contracts index acd4ff7..99eda22 160000 --- a/lib/openzeppelin-contracts +++ b/lib/openzeppelin-contracts @@ -1 +1 @@ -Subproject commit acd4ff74de833399287ed6b31b4debf6b2b35527 +Subproject commit 99eda2225c0246c265c902475c47ec0c6321f119 diff --git a/lib/solady b/lib/solady index a8fbe33..834bbc4 160000 --- a/lib/solady +++ b/lib/solady @@ -1 +1 @@ -Subproject commit a8fbe33fdd8897a8a3f23d59193296d3a0fd41b7 +Subproject commit 834bbc4fd366ca8bce8c532a0e3b34eca6be709c diff --git a/lib/the-compact b/lib/the-compact index 99893a2..c5c9ad7 160000 --- a/lib/the-compact +++ b/lib/the-compact @@ -1 +1 @@ -Subproject commit 99893a28eb48de0f1a73f01b376d4daf0d05a56d +Subproject commit c5c9ad7aec1f4783f43f26187f7a0dbaa9c65078 diff --git a/lib/tribunal b/lib/tribunal new file mode 160000 index 0000000..21b6e80 --- /dev/null +++ b/lib/tribunal @@ -0,0 +1 @@ +Subproject commit 21b6e8096d8d940789eeb63c5d281e490fd63025 diff --git a/snapshots/ERC7683Allocator_open.json b/snapshots/ERC7683Allocator_open.json new file mode 100644 index 0000000..9c7f3d9 --- /dev/null +++ b/snapshots/ERC7683Allocator_open.json @@ -0,0 +1,3 @@ +{ + "open_simpleOrder": "168301" +} \ No newline at end of file diff --git a/snapshots/ERC7683Allocator_openFor.json b/snapshots/ERC7683Allocator_openFor.json new file mode 100644 index 0000000..c719551 --- /dev/null +++ b/snapshots/ERC7683Allocator_openFor.json @@ -0,0 +1,3 @@ +{ + "openFor_simpleOrder_userHimself": "171750" +} \ No newline at end of file diff --git a/snapshots/HybridAllocatorTest.json b/snapshots/HybridAllocatorTest.json new file mode 100644 index 0000000..c5840e6 --- /dev/null +++ b/snapshots/HybridAllocatorTest.json @@ -0,0 +1,10 @@ +{ + "allocateAndRegister_erc20Token": "187668", + "allocateAndRegister_erc20Token_emptyAmountInput": "188578", + "allocateAndRegister_multipleTokens": "223574", + "allocateAndRegister_nativeToken": "139204", + "allocateAndRegister_nativeToken_emptyAmountInput": "139040", + "allocateAndRegister_second_erc20Token": "114874", + "allocateAndRegister_second_nativeToken": "104840", + "hybrid_execute_single": "174395" +} \ No newline at end of file diff --git a/snapshots/OnChainAllocatorTest.json b/snapshots/OnChainAllocatorTest.json new file mode 100644 index 0000000..ec5af8f --- /dev/null +++ b/snapshots/OnChainAllocatorTest.json @@ -0,0 +1,9 @@ +{ + "allocateFor_success_withRegistration": "133753", + "allocate_and_delete_expired_allocation": "65921", + "allocate_erc20": "129192", + "allocate_native": "128952", + "allocate_second_erc20": "97204", + "onchain_execute_double": "347707", + "onchain_execute_single": "219699" +} \ No newline at end of file diff --git a/src/allocators/ERC7683Allocator.sol b/src/allocators/ERC7683Allocator.sol new file mode 100644 index 0000000..2484833 --- /dev/null +++ b/src/allocators/ERC7683Allocator.sol @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {IOriginSettler} from '../interfaces/ERC7683/IOriginSettler.sol'; +import {IERC7683Allocator} from '../interfaces/IERC7683Allocator.sol'; +import {OnChainAllocator} from './OnChainAllocator.sol'; +import {AllocatorLib as AL} from './lib/AllocatorLib.sol'; +import {ERC7683AllocatorLib as ERC7683AL} from './lib/ERC7683AllocatorLib.sol'; + +import {Tribunal} from '@uniswap/tribunal/Tribunal.sol'; +import {Fill, Mandate, RecipientCallback} from '@uniswap/tribunal/types/TribunalStructs.sol'; + +import {IAllocator} from '@uniswap/the-compact/interfaces/IAllocator.sol'; +import {ITheCompact} from '@uniswap/the-compact/interfaces/ITheCompact.sol'; +import { + COMPACT_TYPEHASH_WITH_MANDATE, + COMPACT_WITH_MANDATE_TYPESTRING, + MANDATE_BATCH_COMPACT_TYPEHASH, + MANDATE_FILL_TYPEHASH, + MANDATE_RECIPIENT_CALLBACK_TYPEHASH, + MANDATE_TYPEHASH +} from '@uniswap/tribunal/types/TribunalTypeHashes.sol'; + +import {LibBytes} from '@solady/utils/LibBytes.sol'; +import {BatchCompact, Lock} from '@uniswap/the-compact/types/EIP712Types.sol'; + +/// @title ERC7683Allocator +/// @notice Allocates tokens deposited into the compact and broadcasts orders following the ERC7683 standard. +/// @dev The contract ensures tokens can not be double spent by a user in a fully decentralized manner. +/// @dev Users can open orders for themselves or for others by providing a signature or the tokens directly. +/// @custom:security-contact security@uniswap.org +contract ERC7683Allocator is OnChainAllocator, IERC7683Allocator { + constructor(address compact) OnChainAllocator(compact) {} + + /// @inheritdoc IOriginSettler + function openFor(GaslessCrossChainOrder calldata order, bytes calldata sponsorSignature, bytes calldata) external { + ( + IERC7683Allocator.Order calldata orderData, + uint32 deposit, + bytes32 mandateHash, + IOriginSettler.ResolvedCrossChainOrder memory resolvedOrder + ) = ERC7683AL.openForPreparation(order, sponsorSignature); + + // Early revert if the expected nonce is not the next nonce and the order does not include a deposit + if (deposit == 0 && order.nonce != _getNonce(address(0), order.user)) { + revert InvalidNonce(order.nonce, _getNonce(address(0), order.user)); + } + + uint256 nonce; + if (deposit == 0) { + // Register the allocation on chain + (, nonce) = allocateFor( + order.user, + orderData.commitments, + orderData.arbiter, + order.openDeadline, + COMPACT_TYPEHASH_WITH_MANDATE, + mandateHash, + sponsorSignature + ); + } else { + // Register the allocation on chain by using a deposit + uint256[] memory registeredAmounts; + (, registeredAmounts, nonce) = allocateAndRegister( + order.user, + orderData.commitments, + orderData.arbiter, + order.openDeadline, + COMPACT_TYPEHASH_WITH_MANDATE, + mandateHash + ); + + // We ignore order.nonce and use the one assigned by the allocator + resolvedOrder.orderId = bytes32(nonce); + + // Update the resolved order with the registered amounts + for (uint256 i = 0; i < orderData.commitments.length; i++) { + resolvedOrder.minReceived[i].amount = registeredAmounts[i]; + } + } + // Emit an open event + emit Open(bytes32(nonce), resolvedOrder); + } + + /// @inheritdoc IOriginSettler + function open(OnchainCrossChainOrder calldata order) external { + (IERC7683Allocator.Order calldata orderData, uint32 expires, bytes32 mandateHash, bytes32[] memory fillHashes) = + ERC7683AL.openPreparation(order); + + // Register the allocation on chain + (bytes32 claimHash, uint256 nonce) = + allocate(orderData.commitments, orderData.arbiter, expires, COMPACT_TYPEHASH_WITH_MANDATE, mandateHash); + + // Ensure a registration exists before opening the order + if (!ITheCompact(COMPACT_CONTRACT).isRegistered(msg.sender, claimHash, COMPACT_TYPEHASH_WITH_MANDATE)) { + revert InvalidRegistration(msg.sender, claimHash); + } + + ResolvedCrossChainOrder memory resolvedOrder = + ERC7683AL.resolveOrder(msg.sender, nonce, expires, fillHashes, orderData, LibBytes.emptyCalldata()); + + // Emit an open event + emit Open(bytes32(nonce), resolvedOrder); + } + + /// @inheritdoc IOriginSettler + function resolveFor(GaslessCrossChainOrder calldata order, bytes calldata) + external + view + returns (ResolvedCrossChainOrder memory) + { + (, uint32 deposit,, IOriginSettler.ResolvedCrossChainOrder memory resolvedOrder) = + ERC7683AL.openForPreparation(order, LibBytes.emptyCalldata()); + + // Revert if the expected nonce is not the next nonce and the order does not include a deposit + if (deposit == 0 && order.nonce != _getNonce(address(0), order.user)) { + revert InvalidNonce(order.nonce, _getNonce(address(0), order.user)); + } else if (deposit == 1) { + // We ignore the order.nonce and use the one assigned by the allocator + resolvedOrder.orderId = bytes32(_getNonce(msg.sender, order.user)); + } + + return resolvedOrder; + } + + /// @inheritdoc IOriginSettler + function resolve(OnchainCrossChainOrder calldata order) external view returns (ResolvedCrossChainOrder memory) { + (IERC7683Allocator.Order calldata orderData, uint32 expires,, bytes32[] memory fillHashes) = + ERC7683AL.openPreparation(order); + + return ERC7683AL.resolveOrder( + msg.sender, _getNonce(address(0), msg.sender), expires, fillHashes, orderData, LibBytes.emptyCalldata() + ); + } + + /// @inheritdoc IERC7683Allocator + function getCompactWitnessTypeString() external pure returns (string memory) { + return COMPACT_WITH_MANDATE_TYPESTRING; + } + + /// @inheritdoc IERC7683Allocator + function getNonce(GaslessCrossChainOrder calldata order, address caller) external view returns (uint256 nonce) { + (, uint32 deposit) = ERC7683AL.decodeOrderData(order.orderData); + deposit = ERC7683AL.sanitizeBool(deposit); + + caller = address(uint160(deposit * uint160(caller))); // for a deposit, the nonce will be scoped to the caller + user + + return _getNonce(address(caller), order.user); + } + + /// @inheritdoc IERC7683Allocator + function createFillerData(address claimant) external pure returns (bytes memory fillerData) { + return abi.encode(claimant); + } +} diff --git a/src/allocators/HybridAllocator.sol b/src/allocators/HybridAllocator.sol new file mode 100644 index 0000000..8946ca7 --- /dev/null +++ b/src/allocators/HybridAllocator.sol @@ -0,0 +1,269 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {SafeTransferLib} from '@solady/utils/SafeTransferLib.sol'; +import {Lock} from '@uniswap/the-compact/types/EIP712Types.sol'; + +import {IERC20} from '@openzeppelin/contracts/token/ERC20/IERC20.sol'; + +import {AllocatorLib as AL} from './lib/AllocatorLib.sol'; +import {IAllocator} from '@uniswap/the-compact/interfaces/IAllocator.sol'; +import {ITheCompact} from '@uniswap/the-compact/interfaces/ITheCompact.sol'; +import {IHybridAllocator} from 'src/interfaces/IHybridAllocator.sol'; + +contract HybridAllocator is IHybridAllocator { + uint96 public immutable ALLOCATOR_ID; + ITheCompact internal immutable _COMPACT; + bytes32 internal immutable _COMPACT_DOMAIN_SEPARATOR; + + mapping(bytes32 => bool) internal claims; + + /// @dev The off chain allocator must use a uint256 nonce where the first 160 bits are the sponsors address to ensure no nonce collisions + uint96 public nonces; + uint256 public signerCount; + mapping(address => bool) public signers; + + modifier onlySigner() { + if (!signers[msg.sender]) { + revert InvalidSigner(); + } + _; + } + + constructor(address compact_, address signer_) { + if (signer_ == address(0)) { + revert InvalidSigner(); + } + _COMPACT = ITheCompact(compact_); + _COMPACT_DOMAIN_SEPARATOR = _COMPACT.DOMAIN_SEPARATOR(); + try _COMPACT.__registerAllocator(address(this), '') returns (uint96 allocatorId) { + ALLOCATOR_ID = allocatorId; + } catch (bytes memory lowLevelData) { + // Allocator is already registered. Check the registered allocator in the revert data + if (lowLevelData.length != 0x44) { + revert InvalidAllocatorRegistration(address(0)); + } + bytes4 errorSelector = bytes4(lowLevelData); + if (errorSelector != 0xc18b0e97) { + // Did not revert with 'ALLOCATOR_ALREADY_REGISTERED_ERROR' + revert InvalidAllocatorRegistration(address(0)); + } + uint96 allocatorId; + address registeredAllocator; + assembly { + allocatorId := mload(add(lowLevelData, 0x24)) + registeredAllocator := mload(add(lowLevelData, 0x44)) + } + if (registeredAllocator != address(this)) { + revert InvalidAllocatorRegistration(registeredAllocator); + } + ALLOCATOR_ID = allocatorId; + } + + signers[signer_] = true; + signerCount++; + } + + /// @inheritdoc IHybridAllocator + function addSigner(address signer_) external onlySigner { + if (signer_ == address(0) || signers[signer_]) { + revert InvalidSigner(); + } + signers[signer_] = true; + signerCount++; + } + + /// @inheritdoc IHybridAllocator + function removeSigner(address signer_) external onlySigner { + if (signerCount == 1 || !signers[signer_]) { + revert LastSigner(); + } + signers[signer_] = false; + signerCount--; + } + + /// @inheritdoc IHybridAllocator + function replaceSigner(address newSigner_) external onlySigner { + if (newSigner_ == address(0) || signers[newSigner_]) { + revert InvalidSigner(); + } + signers[msg.sender] = false; + signers[newSigner_] = true; + } + + /// @inheritdoc IAllocator + function attest(address, /*operator*/ address, /*from*/ address, /*to*/ uint256, /*id*/ uint256 /*amount*/ ) + external + pure + returns (bytes4) + { + revert Unsupported(); + } + + /// @inheritdoc IHybridAllocator + function allocateAndRegister( + address recipient, + uint256[2][] memory idsAndAmounts, + address arbiter, + uint256 expires, + bytes32 typehash, + bytes32 witness + ) public payable returns (bytes32, uint256[] memory, uint256) { + idsAndAmounts = _actualIdsAndAmounts(idsAndAmounts); + + (bytes32 claimHash, uint256[] memory registeredAmounts) = _COMPACT.batchDepositAndRegisterFor{value: msg.value}( + recipient, idsAndAmounts, arbiter, ++nonces, expires, typehash, witness + ); + + Lock[] memory commitments = new Lock[](idsAndAmounts.length); + for (uint256 i = 0; i < idsAndAmounts.length; i++) { + commitments[i] = Lock({ + lockTag: bytes12(bytes32(idsAndAmounts[i][0])), + token: address(uint160(idsAndAmounts[i][0])), + amount: registeredAmounts[i] + }); + } + + // Allocate the claim + claims[claimHash] = true; + + emit Allocated(recipient, commitments, nonces, expires, claimHash); + + return (claimHash, registeredAmounts, nonces); + } + + function prepareAllocation( + address recipient, + uint256[2][] calldata idsAndAmounts, + address arbiter, + uint256 expires, + bytes32 typehash, + bytes32 witness, + bytes calldata /* orderData */ + ) external returns (uint256 nonce) { + nonce = nonces + 1; + AL.prepareAllocation(address(_COMPACT), nonce, recipient, idsAndAmounts, arbiter, expires, typehash, witness); + } + + function executeAllocation( + address recipient, + uint256[2][] calldata idsAndAmounts, + address arbiter, + uint256 expires, + bytes32 typehash, + bytes32 witness, + bytes calldata /* orderData */ + ) external { + uint256 nonce = ++nonces; + + (bytes32 claimHash, Lock[] memory commitments) = AL.executeAllocation( + address(_COMPACT), nonce, recipient, idsAndAmounts, arbiter, expires, typehash, witness + ); + + // Allocate the claim + claims[claimHash] = true; + + emit Allocated(recipient, commitments, nonce, expires, claimHash); + } + + /// @inheritdoc IAllocator + function authorizeClaim( + bytes32 claimHash, + address, /*arbiter*/ + address, /*sponsor*/ + uint256, /*nonce*/ + uint256, /*expires*/ + uint256[2][] calldata, /*idsAndAmounts*/ + bytes calldata allocatorData_ + ) external virtual returns (bytes4) { + if (msg.sender != address(_COMPACT)) { + revert InvalidCaller(msg.sender, address(_COMPACT)); + } + // The compact will check the validity of the nonce and expiration + + // Check if the claim was allocated on chain + if (claims[claimHash]) { + delete claims[claimHash]; + + // Authorize the claim + return IAllocator.authorizeClaim.selector; + } + + // Check the allocator data for a valid signature by an authorized signer + bytes32 digest = keccak256(abi.encodePacked(bytes2(0x1901), _COMPACT_DOMAIN_SEPARATOR, claimHash)); + if (!_checkSignature(digest, allocatorData_)) { + revert InvalidSignature(); + } + + // Authorize the claim + return IAllocator.authorizeClaim.selector; + } + + /// @inheritdoc IAllocator + function isClaimAuthorized( + bytes32 claimHash, // The message hash representing the claim. + address, /*arbiter*/ // The account tasked with verifying and submitting the claim. + address, /*sponsor*/ // The account to source the tokens from. + uint256, /*nonce*/ // A parameter to enforce replay protection, scoped to allocator. + uint256, /*expires*/ // The time at which the claim expires. + uint256[2][] calldata, /*idsAndAmounts*/ // The allocated token IDs and amounts. + bytes calldata allocatorData // Arbitrary data provided by the arbiter. + ) external view virtual returns (bool) { + if (claims[claimHash]) { + return true; + } + + // Check the allocator data for a valid signature by an authorized allocator address + bytes32 digest = keccak256(abi.encodePacked(bytes2(0x1901), _COMPACT_DOMAIN_SEPARATOR, claimHash)); + return _checkSignature(digest, allocatorData); + } + + function _actualIdsAndAmounts(uint256[2][] memory idsAndAmounts) internal returns (uint256[2][] memory) { + uint256 idIndex = 0; + uint256 idsLength = idsAndAmounts.length; + if (idsLength == 0) { + revert InvalidIds(); + } + + // Check for native token - Native tokens must always be the first id + if (AL.splitToken(idsAndAmounts[0][0]) == address(0)) { + // Check allocator id + if (AL.splitAllocatorId(idsAndAmounts[0][0]) != ALLOCATOR_ID) { + revert InvalidAllocatorId(AL.splitAllocatorId(idsAndAmounts[0][0]), ALLOCATOR_ID); + } + if (idsAndAmounts[0][1] != 0 && msg.value != idsAndAmounts[0][1]) { + revert InvalidValue(msg.value, idsAndAmounts[0][1]); + } + idsAndAmounts[0][1] = msg.value; + + idIndex++; + } + + for (; idIndex < idsLength; idIndex++) { + (uint96 allocatorId, address token) = AL.splitId(idsAndAmounts[idIndex][0]); + + // Check allocator id + if (allocatorId != ALLOCATOR_ID) { + revert InvalidAllocatorId(allocatorId, ALLOCATOR_ID); + } + + if (idsAndAmounts[idIndex][1] == 0) { + // Amount is derived from the allocators token balance + idsAndAmounts[idIndex][1] = IERC20(token).balanceOf(address(this)); + } + + if (IERC20(token).allowance(address(this), address(_COMPACT)) < idsAndAmounts[idIndex][1]) { + SafeTransferLib.safeApproveWithRetry(token, address(_COMPACT), type(uint256).max); + } + } + + return idsAndAmounts; + } + + function _checkSignature(bytes32 digest, bytes calldata signature) internal view returns (bool) { + // Check if the signer is an authorized allocator address + address signer = AL.recoverSigner(digest, signature); + return signers[signer] && signer != address(0); + } +} diff --git a/src/allocators/HybridERC7683.sol b/src/allocators/HybridERC7683.sol new file mode 100644 index 0000000..3e7854e --- /dev/null +++ b/src/allocators/HybridERC7683.sol @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {ERC7683AllocatorLib as ERC7683AL} from './lib/ERC7683AllocatorLib.sol'; +import {LibBytes} from '@solady/utils/LibBytes.sol'; +import {IAllocator} from '@uniswap/the-compact/interfaces/IAllocator.sol'; +import {BatchCompact, Lock} from '@uniswap/the-compact/types/EIP712Types.sol'; +import {Tribunal} from '@uniswap/tribunal/Tribunal.sol'; +import {Fill, Mandate} from '@uniswap/tribunal/types/TribunalStructs.sol'; + +import { + COMPACT_TYPEHASH_WITH_MANDATE, + COMPACT_WITH_MANDATE_TYPESTRING, + MANDATE_BATCH_COMPACT_TYPEHASH, + MANDATE_FILL_TYPEHASH, + MANDATE_RECIPIENT_CALLBACK_TYPEHASH, + MANDATE_TYPEHASH +} from '@uniswap/tribunal/types/TribunalTypeHashes.sol'; +import {HybridAllocator} from 'src/allocators/HybridAllocator.sol'; +import {IERC7683Allocator} from 'src/interfaces/IERC7683Allocator.sol'; + +import {AllocatorLib as AL} from 'src/allocators/lib/AllocatorLib.sol'; +import {IOriginSettler} from 'src/interfaces/ERC7683/IOriginSettler.sol'; + +/// @title HybridERC7683 +/// @notice Hybrid allocator that can be used with the ERC7683 standard and the Uniswap Tribunal as the destination settler. +/// @custom:security-contact security@uniswap.org +contract HybridERC7683 is HybridAllocator, IERC7683Allocator { + error OnlyDepositsAllowed(); + + constructor(address compact, address signer) HybridAllocator(compact, signer) {} + + /// @inheritdoc IOriginSettler + function openFor(GaslessCrossChainOrder calldata order, bytes calldata sponsorSignature, bytes calldata) external { + ( + IERC7683Allocator.Order calldata orderData, + uint32 deposit, + bytes32 mandateHash, + IOriginSettler.ResolvedCrossChainOrder memory resolvedOrder + ) = ERC7683AL.openForPreparation(order, sponsorSignature); + + if (deposit == 0) { + // Hybrid Allocator requires a deposit + revert OnlyDepositsAllowed(); + } else { + // Create idsAndAmounts + uint256[2][] memory idsAndAmounts = new uint256[2][](orderData.commitments.length); + for (uint256 i = 0; i < orderData.commitments.length; i++) { + idsAndAmounts[i][0] = AL.toId(orderData.commitments[i].lockTag, orderData.commitments[i].token); + idsAndAmounts[i][1] = orderData.commitments[i].amount; + } + + // Register the allocation on chain by using a deposit + (, uint256[] memory registeredAmounts, uint256 nonce) = allocateAndRegister( + order.user, + idsAndAmounts, + orderData.arbiter, + order.openDeadline, + COMPACT_TYPEHASH_WITH_MANDATE, + mandateHash + ); + + // We ignore the order.nonce and use the one assigned by the hybrid allocator + resolvedOrder.orderId = bytes32(nonce); + + // Update the resolved order with the registered amounts + for (uint256 i = 0; i < orderData.commitments.length; i++) { + resolvedOrder.minReceived[i].amount = registeredAmounts[i]; + } + + // Emit an open event + emit Open(bytes32(nonce), resolvedOrder); + } + } + + /// @inheritdoc IOriginSettler + function open(OnchainCrossChainOrder calldata order) external { + (IERC7683Allocator.Order calldata orderData, uint32 expires, bytes32 mandateHash, bytes32[] memory fillHashes) = + ERC7683AL.openPreparation(order); + + // Create idsAndAmounts + uint256[2][] memory idsAndAmounts = new uint256[2][](orderData.commitments.length); + for (uint256 i = 0; i < orderData.commitments.length; i++) { + idsAndAmounts[i][0] = AL.toId(orderData.commitments[i].lockTag, orderData.commitments[i].token); + idsAndAmounts[i][1] = orderData.commitments[i].amount; + } + + // deposit the the tokens into the compact and register the claim + (, uint256[] memory registeredAmounts, uint256 nonce) = allocateAndRegister( + msg.sender, idsAndAmounts, orderData.arbiter, expires, COMPACT_TYPEHASH_WITH_MANDATE, mandateHash + ); + ResolvedCrossChainOrder memory resolvedOrder = + ERC7683AL.resolveOrder(msg.sender, nonce, expires, fillHashes, orderData, LibBytes.emptyCalldata()); + + // Update the resolved order with the registered amounts + for (uint256 i = 0; i < orderData.commitments.length; i++) { + resolvedOrder.minReceived[i].amount = registeredAmounts[i]; + } + + // Emit an open event + emit Open(bytes32(nonce), resolvedOrder); + } + + /// @inheritdoc IOriginSettler + function resolveFor(GaslessCrossChainOrder calldata order, bytes calldata /*originFillerData*/ ) + external + view + returns (ResolvedCrossChainOrder memory) + { + (, uint32 deposit,, IOriginSettler.ResolvedCrossChainOrder memory resolvedOrder) = + ERC7683AL.openForPreparation(order, LibBytes.emptyCalldata()); + + if (deposit == 0) { + // Hybrid Allocator requires a deposit + revert OnlyDepositsAllowed(); + } + + // We ignore the order.nonce and use the one assigned by the hybrid allocator + resolvedOrder.orderId = bytes32(uint256(nonces) + 1); + + return resolvedOrder; + } + + /// @inheritdoc IOriginSettler + function resolve(OnchainCrossChainOrder calldata order) external view returns (ResolvedCrossChainOrder memory) { + (IERC7683Allocator.Order calldata orderData, uint32 expires,, bytes32[] memory fillHashes) = + ERC7683AL.openPreparation(order); + + return ERC7683AL.resolveOrder(msg.sender, nonces + 1, expires, fillHashes, orderData, LibBytes.emptyCalldata()); + } + + /// @inheritdoc IERC7683Allocator + function getCompactWitnessTypeString() external pure returns (string memory) { + return COMPACT_WITH_MANDATE_TYPESTRING; + } + + /// @inheritdoc IERC7683Allocator + function getNonce(GaslessCrossChainOrder calldata order, address) external view returns (uint256 nonce) { + (, uint32 deposit) = ERC7683AL.decodeOrderData(order.orderData); + deposit = ERC7683AL.sanitizeBool(deposit); + + if (deposit == 0) { + // Hybrid Allocator requires a deposit + revert OnlyDepositsAllowed(); + } + + return nonces + 1; + } + + /// @inheritdoc IERC7683Allocator + function createFillerData(address claimant) external pure returns (bytes memory fillerData) { + return abi.encode(claimant); + } +} diff --git a/src/allocators/OnChainAllocator.sol b/src/allocators/OnChainAllocator.sol new file mode 100644 index 0000000..f28b22b --- /dev/null +++ b/src/allocators/OnChainAllocator.sol @@ -0,0 +1,527 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {IOnChainAllocator} from '../interfaces/IOnChainAllocator.sol'; + +import {AllocatorLib as AL} from './lib/AllocatorLib.sol'; +import {IERC20} from '@openzeppelin/contracts/token/ERC20/IERC20.sol'; +import {ERC6909} from '@solady/tokens/ERC6909.sol'; +import {SafeTransferLib} from '@solady/utils/SafeTransferLib.sol'; +import {IAllocator} from '@uniswap/the-compact/interfaces/IAllocator.sol'; +import {ITheCompact} from '@uniswap/the-compact/interfaces/ITheCompact.sol'; +import {Lock} from '@uniswap/the-compact/types/EIP712Types.sol'; + +/// @title OnChainAllocator +/// @notice Allocates tokens deposited into the compact. +/// @dev The contract ensures tokens can not be double spent by a user in a fully decentralized manner. +/// @dev Users can open orders for themselves or for others by providing a signature or the tokens directly. +contract OnChainAllocator is IOnChainAllocator { + address public immutable COMPACT_CONTRACT; + bytes32 public immutable COMPACT_DOMAIN_SEPARATOR; + uint96 public immutable ALLOCATOR_ID; + + mapping(bytes32 tokenHash => Allocation[] allocations) internal _allocations; + + mapping(address user => uint96 nonce) public nonces; + + modifier onlyCompact() { + if (msg.sender != COMPACT_CONTRACT) { + revert InvalidCaller(msg.sender, COMPACT_CONTRACT); + } + _; + } + + constructor(address compactContract_) { + COMPACT_CONTRACT = compactContract_; + COMPACT_DOMAIN_SEPARATOR = ITheCompact(COMPACT_CONTRACT).DOMAIN_SEPARATOR(); + try ITheCompact(COMPACT_CONTRACT).__registerAllocator(address(this), '') returns (uint96 allocatorId) { + ALLOCATOR_ID = allocatorId; + } catch (bytes memory lowLevelData) { + // Allocator is already registered. Check the registered allocator in the revert data + if (lowLevelData.length != 0x44) { + revert InvalidAllocatorRegistration(address(0)); + } + bytes4 errorSelector = bytes4(lowLevelData); + if (errorSelector != 0xc18b0e97) { + // Did not revert with 'ALLOCATOR_ALREADY_REGISTERED_ERROR' + revert InvalidAllocatorRegistration(address(0)); + } + uint96 allocatorId; + address registeredAllocator; + assembly { + allocatorId := mload(add(lowLevelData, 0x24)) + registeredAllocator := mload(add(lowLevelData, 0x44)) + } + if (registeredAllocator != address(this)) { + revert InvalidAllocatorRegistration(registeredAllocator); + } + ALLOCATOR_ID = allocatorId; + } + } + + /// @inheritdoc IOnChainAllocator + function allocate(Lock[] calldata commitments, address arbiter, uint32 expires, bytes32 typehash, bytes32 witness) + public + returns (bytes32 claimHash, uint256 claimNonce) + { + (claimHash, claimNonce) = _allocate(msg.sender, commitments, arbiter, expires, typehash, witness); + + emit Allocated(msg.sender, commitments, claimNonce, expires, claimHash); + } + + /// @inheritdoc IOnChainAllocator + function allocateFor( + address sponsor, + Lock[] calldata commitments, + address arbiter, + uint32 expires, + bytes32 typehash, + bytes32 witness, + bytes calldata signature + ) public returns (bytes32 claimHash, uint256 claimNonce) { + (claimHash, claimNonce) = _allocate(sponsor, commitments, arbiter, expires, typehash, witness); + + // We check for the length, which means this could also be triggered by a zero length signature provided in the openFor function. + // This enables relaying of orders if the claim was registered on the compact. + if (signature.length > 0) { + // confirm the provided signature is valid + bytes32 digest = keccak256(abi.encodePacked(bytes2(0x1901), COMPACT_DOMAIN_SEPARATOR, claimHash)); + address signer_ = AL.recoverSigner(digest, signature); + if (sponsor != signer_ || signer_ == address(0)) { + revert InvalidSignature(signer_, sponsor); + } + } else { + // confirm the claim hash is registered on the compact + if (!ITheCompact(COMPACT_CONTRACT).isRegistered(sponsor, claimHash, typehash)) { + revert InvalidRegistration(sponsor, claimHash); + } + } + emit Allocated(sponsor, commitments, claimNonce, expires, claimHash); + } + + /// @inheritdoc IOnChainAllocator + function allocateAndRegister( + address recipient, + Lock[] calldata commitments, + address arbiter, + uint32 expires, + bytes32 typehash, + bytes32 witness + ) public returns (bytes32 claimHash, uint256[] memory registeredAmounts, uint256 nonce) { + nonce = _getAndUpdateNonce(msg.sender, recipient); + + uint256[2][] memory idsAndAmounts = new uint256[2][](commitments.length); + + uint256 minResetPeriod = type(uint256).max; + for (uint256 i = 0; i < commitments.length; i++) { + minResetPeriod = _checkInput(commitments[i], recipient, expires, minResetPeriod); + idsAndAmounts[i][0] = AL.toId(commitments[i].lockTag, commitments[i].token); + uint224 amount = uint224(commitments[i].amount); + + // If the amount is 0, we use the balance of the contract to deposit. + if (amount == 0) { + amount = uint224(IERC20(commitments[i].token).balanceOf(address(this))); + } + idsAndAmounts[i][1] = amount; + + // Approve the compact contract to spend the tokens. + if (IERC20(commitments[i].token).allowance(address(this), COMPACT_CONTRACT) < amount) { + SafeTransferLib.safeApproveWithRetry(commitments[i].token, COMPACT_CONTRACT, type(uint256).max); + } + } + // Ensure expiration is not bigger then the smallest reset period + if (expires >= block.timestamp + minResetPeriod) { + revert InvalidExpiration(expires, block.timestamp + minResetPeriod); + } + + // Deposit the tokens and register the claim in the compact + (claimHash, registeredAmounts) = ITheCompact(COMPACT_CONTRACT).batchDepositAndRegisterFor( + recipient, idsAndAmounts, arbiter, nonce, expires, typehash, witness + ); + + // Update the commitments and store the allocation + Lock[] memory registeredCommitments = + _updateCommitmentsAndStoreAllocation(recipient, registeredAmounts, commitments, expires, claimHash); + + emit Allocated(recipient, registeredCommitments, nonce, expires, claimHash); + + return (claimHash, registeredAmounts, nonce); + } + + function _updateCommitmentsAndStoreAllocation( + address recipient, + uint256[] memory registeredAmounts, + Lock[] memory commitments, + uint32 expires, + bytes32 claimHash + ) internal returns (Lock[] memory) { + // Store the allocation + for (uint256 i = 0; i < registeredAmounts.length; i++) { + // Update the allocations with the actual registered amounts + uint224 amount = uint224(registeredAmounts[i]); + commitments[i].amount = amount; + + // Store the allocation + _storeAllocation(commitments[i].lockTag, commitments[i].token, amount, recipient, expires, claimHash); + } + + return commitments; + } + + function prepareAllocation( + address recipient, + uint256[2][] calldata idsAndAmounts, + address arbiter, + uint256 expires, + bytes32 typehash, + bytes32 witness, + bytes calldata /* orderData */ + ) external returns (uint256 nonce) { + uint32 expiration = uint32(expires); + nonce = _getNonce(msg.sender, recipient); + AL.prepareAllocation(COMPACT_CONTRACT, nonce, recipient, idsAndAmounts, arbiter, expiration, typehash, witness); + + return nonce; + } + + function executeAllocation( + address recipient, + uint256[2][] calldata idsAndAmounts, + address arbiter, + uint256 expires, + bytes32 typehash, + bytes32 witness, + bytes calldata /* orderData */ + ) external { + uint256 nonce = _getAndUpdateNonce(msg.sender, recipient); + uint32 expiration = uint32(expires); + + (bytes32 claimHash, Lock[] memory commitments) = + _executeAllocation(nonce, recipient, idsAndAmounts, arbiter, expiration, typehash, witness); + + emit Allocated(recipient, commitments, nonce, expiration, claimHash); + } + + function _executeAllocation( + uint256 nonce, + address recipient, + uint256[2][] calldata idsAndAmounts, + address arbiter, + uint32 expires, + bytes32 typehash, + bytes32 witness + ) internal returns (bytes32, Lock[] memory) { + (bytes32 claimHash, Lock[] memory commitments) = + AL.executeAllocation(COMPACT_CONTRACT, nonce, recipient, idsAndAmounts, arbiter, expires, typehash, witness); + + // Allocate the claim + for (uint256 i = 0; i < commitments.length; i++) { + // Check the amount fits in the supported range + if (commitments[i].amount > type(uint224).max) { + revert InvalidAmount(commitments[i].amount); + } + + _storeAllocation( + commitments[i].lockTag, + commitments[i].token, + uint224(commitments[i].amount), + recipient, + expires, + claimHash + ); + } + + return (claimHash, commitments); + } + + /// @inheritdoc IAllocator + function attest(address, address from_, address, uint256 id_, uint256 amount_) external returns (bytes4) { + // Can be called by anyone, as this will only clean up expired allocations. + uint256 balance = ERC6909(COMPACT_CONTRACT).balanceOf(from_, id_); + + // Check unlocked balance + bytes32 tokenHash = _getTokenHash(id_, from_); + uint256 allocatedBalance = _allocatedBalance(tokenHash); + uint256 fullAmount = amount_ + allocatedBalance; + + if (balance < fullAmount) { + revert InsufficientBalance(from_, id_, balance - allocatedBalance, amount_); + } + + return this.attest.selector; + } + + /// @inheritdoc IAllocator + function authorizeClaim( + bytes32 claimHash, // The message hash representing the claim. + address, /*arbiter*/ // The account tasked with verifying and submitting the claim. + address sponsor, // The account sponsoring the claim. + uint256, /*nonce*/ // A parameter to enforce replay protection, scoped to allocator. + uint256, /*expires*/ // The time at which the claim expires. + uint256[2][] calldata idsAndAmounts, // The allocated token IDs and amounts. + bytes calldata /*allocatorData*/ // Arbitrary data provided by the arbiter. + ) public virtual onlyCompact returns (bytes4) { + for (uint256 i = 0; i < idsAndAmounts.length; i++) { + bytes32 tokenHash = _getTokenHash(idsAndAmounts[i][0], sponsor); + + if (_verifyClaim(tokenHash, claimHash)) { + // Continue even if the claim is already verified to delete the other allocations. + continue; + } + + // claim could not be verified + revert InvalidClaim(claimHash); + } + + return this.authorizeClaim.selector; + } + + /// @inheritdoc IAllocator + function isClaimAuthorized( + bytes32 claimHash, + address, /*arbiter*/ // The account tasked with verifying and submitting the claim. + address sponsor, // The account sponsoring the claim. + uint256, /*nonce*/ // A parameter to enforce replay protection, scoped to allocator. + uint256 expires, // The time at which the claim expires. + uint256[2][] calldata idsAndAmounts, // The allocated token IDs and amounts. + bytes calldata /*allocatorData*/ // Arbitrary data provided by the arbiter. + ) public view virtual returns (bool) { + if (expires < block.timestamp) { + return false; + } + + // We only need to check the first id to confirm or deny the claim. + bytes32 tokenHash = _getTokenHash(idsAndAmounts[0][0], sponsor); + Allocation[] memory allocations = _allocations[tokenHash]; + for (uint256 j = 0; j < allocations.length; j++) { + if (allocations[j].claimHash == claimHash) { + return true; + } + } + + return false; + } + + function _allocate( + address sponsor, + Lock[] calldata commitments, + address arbiter, + uint32 expires, + bytes32 typehash, + bytes32 witness + ) internal returns (bytes32 claimHash, uint256 nonce) { + if (expires < block.timestamp) { + revert InvalidExpiration(expires, block.timestamp); + } + + nonce = _getAndUpdateNonce(address(0), sponsor); // address(0) as caller allows anyone to relay + bytes32 commitmentsHash = AL.getCommitmentsHash(commitments); + claimHash = AL.getClaimHash(arbiter, sponsor, nonce, expires, commitmentsHash, witness, typehash); + + uint256 minResetPeriod = type(uint256).max; + for (uint256 i = 0; i < commitments.length; i++) { + minResetPeriod = _checkInput(commitments[i], sponsor, expires, minResetPeriod); + bytes32 tokenHash = _checkBalance(sponsor, commitments[i]); + + // Store the allocation + uint224 amount = uint224(commitments[i].amount); + _storeAllocation(tokenHash, amount, expires, claimHash); + } + // Ensure expiration is not bigger then the smallest reset period + if (expires >= block.timestamp + minResetPeriod) { + revert InvalidExpiration(expires, block.timestamp + minResetPeriod - 1); + } + + return (claimHash, nonce); + } + + function _checkInput(Lock calldata commitment, address sponsor, uint32 expires, uint256 minResetPeriod) + internal + view + returns (uint256) + { + // Check the allocator id fits this allocator + if (AL.splitAllocatorId(commitment.lockTag) != ALLOCATOR_ID) { + revert InvalidAllocator(AL.splitAllocatorId(commitment.lockTag), ALLOCATOR_ID); + } + + // Check the amount fits in the supported range + if (commitment.amount > type(uint224).max) { + revert InvalidAmount(commitment.amount); + } + + // Get the reset period for the token id + uint256 duration = AL.toSeconds(commitment.lockTag); + if (duration < minResetPeriod) { + minResetPeriod = duration; + } + + // Ensure no forcedWithdrawal is active for the token id + (, uint256 forcedWithdrawal) = ITheCompact(COMPACT_CONTRACT).getForcedWithdrawalStatus( + sponsor, AL.toId(commitment.lockTag, commitment.token) + ); + if (forcedWithdrawal != 0 && forcedWithdrawal <= expires) { + revert ForceWithdrawalAvailable(expires, forcedWithdrawal); + } + + return minResetPeriod; + } + + function _checkBalance(address sponsor, Lock calldata commitment) internal returns (bytes32 tokenHash) { + // Check the balance of the recipient is sufficient + tokenHash = _getTokenHash(commitment.lockTag, commitment.token, sponsor); + uint256 balance = ERC6909(COMPACT_CONTRACT).balanceOf(sponsor, AL.toId(commitment.lockTag, commitment.token)); + uint256 allocatedBalance = _allocatedBalance(tokenHash); + uint256 requiredBalance = allocatedBalance + commitment.amount; + if (requiredBalance > balance) { + revert InsufficientBalance( + sponsor, AL.toId(commitment.lockTag, commitment.token), balance - allocatedBalance, commitment.amount + ); + } + } + + function _storeAllocation( + bytes12 lockTag, + address token, + uint224 amount, + address recipient, + uint32 expires, + bytes32 claimHash + ) internal { + bytes32 tokenHash = _getTokenHash(lockTag, token, recipient); + _storeAllocation(tokenHash, amount, expires, claimHash); + } + + function _storeAllocation(bytes32 tokenHash, uint224 amount, uint32 expires, bytes32 claimHash) internal { + Allocation memory allocation = Allocation({expires: expires, amount: amount, claimHash: claimHash}); + _allocations[tokenHash].push(allocation); + } + + function _allocatedBalance(bytes32 tokenHash) internal returns (uint256 allocatedBalance) { + // using assembly to only read the allocated balance + expiration slot and skipping the claimHash slot + assembly ("memory-safe") { + // no previous cached balance, calculate the allocated balance + mstore(0x00, tokenHash) + mstore(0x20, _allocations.slot) + // retrieve the array length slot + let arrayLengthSlot := keccak256(0x00, 0x40) + let origLength := sload(arrayLengthSlot) + let length := origLength + // retrieve the arrays content slot + mstore(0x00, arrayLengthSlot) + let contentSlot := keccak256(0x00, 0x20) + for { let i := 0 } lt(i, length) {} { + let slot := add(contentSlot, mul(i, 2)) // 0x40 to skip the claimHash slot + let content := sload(slot) + let expiration := shr(224, shl(224, content)) + if lt(expiration, timestamp()) { + // allocation expired, remove it + let lastSlot := add(contentSlot, mul(sub(length, 1), 2)) + if iszero(eq(slot, lastSlot)) { + // is not the last allocation of the array + let contentLast1 := sload(lastSlot) + let contentLast2 := sload(add(lastSlot, 1)) + sstore(slot, contentLast1) + sstore(add(slot, 1), contentLast2) + } + // remove the last allocation + length := sub(length, 1) + sstore(lastSlot, 0) + sstore(add(lastSlot, 1), 0) + + // repeat the loop at the same index + continue + } + + let amount := shr(32, content) + allocatedBalance := add(allocatedBalance, amount) + + // jump to the next allocation + i := add(i, 1) + } + + if lt(length, origLength) { + // update the array length + sstore(arrayLengthSlot, length) + } + } + } + + function _verifyClaim(bytes32 tokenHash, bytes32 claimHash) internal returns (bool verified) { + // using assembly to only read the claimHash slot and skip the expires/amount slot + assembly ("memory-safe") { + mstore(0x00, tokenHash) + mstore(0x20, _allocations.slot) + let lengthSlot := keccak256(0x00, 0x40) + let length := sload(lengthSlot) + mstore(0x00, lengthSlot) + let contentSlot := keccak256(0x00, 0x20) + for { let i := 0 } lt(i, length) { i := add(i, 1) } { + // Each allocation occupies two consecutive slots: + // first: packed expires/amount; second: claimHash + let first := add(contentSlot, mul(i, 2)) + let second := add(first, 1) + if eq(sload(second), claimHash) { + // Swap-and-pop delete + let lastFirst := add(contentSlot, mul(sub(length, 1), 2)) + let lastSecond := add(lastFirst, 1) + if iszero(eq(first, lastFirst)) { + let contentLast1 := sload(lastFirst) + let contentLast2 := sload(lastSecond) + sstore(first, contentLast1) + sstore(second, contentLast2) + } + + sstore(lastFirst, 0) + sstore(lastSecond, 0) + + // update the array length + sstore(lengthSlot, sub(length, 1)) + + // We return at the first match, no matter if the allocated amounts match. + // If the claim includes the same token multiple times (amounts mismatch), + // we will enter this function again until all entries are deleted. + verified := 1 + break + } + } + } + } + + function _getAndUpdateNonce(address calling, address sponsor) internal returns (uint256 nonce) { + assembly ("memory-safe") { + calling := xor(calling, mul(sponsor, iszero(calling))) + mstore(0x00, calling) + mstore(0x20, nonces.slot) + let nonceSlot := keccak256(0x00, 0x40) + let nonce96 := sload(nonceSlot) + nonce := or(shl(96, calling), add(nonce96, 1)) + sstore(nonceSlot, add(nonce96, 1)) + } + } + + function _getNonce(address calling, address sponsor) internal view returns (uint256 nonce) { + assembly ("memory-safe") { + calling := xor(calling, mul(sponsor, iszero(calling))) + mstore(0x00, calling) + mstore(0x20, nonces.slot) + let nonceSlot := keccak256(0x00, 0x40) + let nonce96 := sload(nonceSlot) + nonce := or(shl(96, calling), add(nonce96, 1)) + } + } + + function _getTokenHash(bytes12 lockTag, address token, address sponsor) internal pure returns (bytes32 tokenHash) { + assembly ("memory-safe") { + mstore(0x00, lockTag) + mstore(0x0c, shl(96, token)) + mstore(0x20, sponsor) + tokenHash := keccak256(0x00, 0x40) + } + } + + function _getTokenHash(uint256 id, address sponsor) internal pure returns (bytes32 tokenHash) { + tokenHash = keccak256(abi.encode(id, sponsor)); + } +} diff --git a/src/allocators/lib/AllocatorLib.sol b/src/allocators/lib/AllocatorLib.sol new file mode 100644 index 0000000..ef06512 --- /dev/null +++ b/src/allocators/lib/AllocatorLib.sol @@ -0,0 +1,221 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import {ERC6909} from '@solady/tokens/ERC6909.sol'; + +import {ITheCompact} from '@uniswap/the-compact/interfaces/ITheCompact.sol'; +import {LOCK_TYPEHASH, Lock} from '@uniswap/the-compact/types/EIP712Types.sol'; + +library AllocatorLib { + // bytes4(keccak256('prepareAllocation(address,uint256[2][],address,uint256,bytes32,bytes32,bytes)')); + bytes4 public constant PREPARE_ALLOCATION_SELECTOR = 0x7ef6597a; + + error InvalidBalanceChange(uint256 newBalance, uint256 oldBalance); + error InvalidPreparation(); + error InvalidRegistration(address recipient, bytes32 claimHash, bytes32 typehash); + + function prepareAllocation( + address compactContract, + uint256 nonce, + address recipient, + uint256[2][] calldata idsAndAmounts, + address arbiter, + uint256 expires, + bytes32 typehash, + bytes32 witness + ) internal { + uint256[] memory ids = new uint256[](idsAndAmounts.length); + for (uint256 i = 0; i < idsAndAmounts.length; i++) { + uint256 id = idsAndAmounts[i][0]; + // Store Id for the identifier + ids[i] = id; + + // Store the current balance to calculate the deposited amounts in `executeAllocation` + uint256 currentBalance = ERC6909(compactContract).balanceOf(recipient, id); + assembly ("memory-safe") { + mstore(0x00, PREPARE_ALLOCATION_SELECTOR) + mstore(0x20, id) + tstore(keccak256(0x00, 0x40), currentBalance) + } + } + + // Store the nonce for the identifier to ensure the same data is used in `executeAllocation` and protect against replay attacks + bytes32 identifier = + keccak256(abi.encode(PREPARE_ALLOCATION_SELECTOR, recipient, ids, arbiter, expires, typehash, witness)); + assembly ("memory-safe") { + tstore(identifier, nonce) + } + } + + function executeAllocation( + address compactContract, + uint256 nonce, + address recipient, + uint256[2][] calldata idsAndAmounts, + address arbiter, + uint256 expires, + bytes32 typehash, + bytes32 witness + ) internal view returns (bytes32 claimHash, Lock[] memory commitments) { + uint256[] memory ids = new uint256[](idsAndAmounts.length); + commitments = new Lock[](idsAndAmounts.length); + bytes32[] memory commitmentHashes = new bytes32[](idsAndAmounts.length); + + // Check actual balance changes + for (uint256 i = 0; i < idsAndAmounts.length; i++) { + // Store Id for the identifier + ids[i] = idsAndAmounts[i][0]; + + uint256 amount = _calculateBalanceChange(compactContract, recipient, ids[i]); + + // Create commitments + bytes12 lockTag = bytes12(bytes32(ids[i])); + address token = address(uint160(ids[i])); + commitmentHashes[i] = keccak256(abi.encode(LOCK_TYPEHASH, lockTag, token, amount)); + commitments[i] = Lock({lockTag: lockTag, token: token, amount: amount}); + } + + // Ensure preparation was called with the same data + bytes32 identifier = + keccak256(abi.encode(PREPARE_ALLOCATION_SELECTOR, recipient, ids, arbiter, expires, typehash, witness)); + uint256 storedNonce; + assembly ("memory-safe") { + storedNonce := tload(identifier) + } + if (nonce != storedNonce) { + revert InvalidPreparation(); + } + + // Check for a valid registration with the actual data + claimHash = getClaimHash( + arbiter, recipient, storedNonce, expires, keccak256(abi.encodePacked(commitmentHashes)), witness, typehash + ); + if (!ITheCompact(compactContract).isRegistered(recipient, claimHash, typehash)) { + revert InvalidRegistration(recipient, claimHash, typehash); + } + + return (claimHash, commitments); + } + + function getCommitmentsHash(Lock[] memory commitments, bytes32 typehash) internal pure returns (bytes32) { + bytes32[] memory commitmentsHashes = new bytes32[](commitments.length); + for (uint256 i = 0; i < commitments.length; i++) { + commitmentsHashes[i] = + keccak256(abi.encode(typehash, commitments[i].lockTag, commitments[i].token, commitments[i].amount)); + } + return keccak256(abi.encodePacked(commitmentsHashes)); + } + + function getCommitmentsHash(Lock[] memory commitments) internal pure returns (bytes32) { + return getCommitmentsHash(commitments, LOCK_TYPEHASH); + } + + function getClaimHash( + address arbiter, + address sponsor, + uint256 nonce, + uint256 expires, + bytes32 commitmentsHash, + bytes32 witness, + bytes32 typehash + ) internal pure returns (bytes32 claimHash) { + assembly ("memory-safe") { + let m := mload(0x40) + mstore(m, typehash) + mstore(add(m, 0x20), arbiter) + mstore(add(m, 0x40), sponsor) + mstore(add(m, 0x60), nonce) + mstore(add(m, 0x80), expires) + mstore(add(m, 0xa0), commitmentsHash) + mstore(add(m, 0xc0), witness) + claimHash := keccak256(m, sub(0xe0, mul(iszero(witness), 0x20))) + } + } + + function recoverSigner(bytes32 digest, bytes calldata signature) internal pure returns (address) { + bytes32 r; + bytes32 s; + uint8 v; + + if (signature.length == 65) { + (r, s) = abi.decode(signature, (bytes32, bytes32)); + v = uint8(signature[64]); + } else if (signature.length == 64) { + bytes32 vs; + (r, vs) = abi.decode(signature, (bytes32, bytes32)); + v = uint8(uint256(vs >> 255) + 27); + s = vs << 1 >> 1; + } else { + return address(0); + } + + return ecrecover(digest, v, r, s); + } + + function splitId(uint256 id) internal pure returns (uint96 allocatorId_, address token_) { + return (splitAllocatorId(id), splitToken(id)); + } + + function splitAllocatorId(uint256 id) internal pure returns (uint96) { + uint96 allocatorId_; + assembly ("memory-safe") { + allocatorId_ := shr(164, shl(4, id)) + } + return allocatorId_; + } + + function splitAllocatorId(bytes12 lockTag) internal pure returns (uint96) { + uint96 allocatorId_; + assembly ("memory-safe") { + allocatorId_ := shr(164, shl(4, lockTag)) + } + return allocatorId_; + } + + function splitToken(uint256 id) internal pure returns (address) { + return address(uint160(id)); + } + + function toId(bytes12 lockTag, address token) internal pure returns (uint256 id) { + assembly ("memory-safe") { + id := or(lockTag, token) + } + } + + function toLock(uint256 id, uint256 amount) internal pure returns (Lock memory) { + return Lock({lockTag: bytes12(bytes32(id)), token: splitToken(id), amount: amount}); + } + + function toSeconds(bytes12 lockTag) internal pure returns (uint256 duration) { + assembly ("memory-safe") { + let resetPeriod := shr(253, shl(1, lockTag)) + + // Bitpacked durations in 24-bit segments: + // 278d00 094890 015180 000f3c 000258 00003c 00000f 000001 + // 30 days 7 days 1 day 1 hour 10 min 1 min 15 sec 1 sec + let bitpacked := 0x278d00094890015180000f3c00025800003c00000f000001 + + // Shift right by period * 24 bits & mask the least significant 24 bits. + duration := and(shr(mul(resetPeriod, 24), bitpacked), 0xffffff) + } + } + + function _calculateBalanceChange(address compactContract, address recipient, uint256 id) + private + view + returns (uint256 amount) + { + // Calculate the balance + uint256 oldBalance; + assembly ("memory-safe") { + mstore(0x00, PREPARE_ALLOCATION_SELECTOR) + mstore(0x20, id) + oldBalance := tload(keccak256(0x00, 0x40)) + } + uint256 newBalance = ERC6909(compactContract).balanceOf(recipient, id); + if (newBalance <= oldBalance) { + revert InvalidBalanceChange(newBalance, oldBalance); + } + return newBalance - oldBalance; + } +} diff --git a/src/allocators/lib/ERC7683AllocatorLib.sol b/src/allocators/lib/ERC7683AllocatorLib.sol new file mode 100644 index 0000000..af4c04b --- /dev/null +++ b/src/allocators/lib/ERC7683AllocatorLib.sol @@ -0,0 +1,334 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {AllocatorLib as AL} from './AllocatorLib.sol'; + +import {BatchCompact, Lock} from '@uniswap/the-compact/types/EIP712Types.sol'; +import {Tribunal} from '@uniswap/tribunal/Tribunal.sol'; +import {Fill, Mandate, RecipientCallback} from '@uniswap/tribunal/types/TribunalStructs.sol'; +import { + COMPACT_TYPEHASH_WITH_MANDATE, + COMPACT_WITH_MANDATE_TYPESTRING, + MANDATE_BATCH_COMPACT_TYPEHASH, + MANDATE_FILL_TYPEHASH, + MANDATE_LOCK_TYPEHASH, + MANDATE_RECIPIENT_CALLBACK_TYPEHASH, + MANDATE_TYPEHASH +} from '@uniswap/tribunal/types/TribunalTypeHashes.sol'; + +import {IOriginSettler} from 'src/interfaces/ERC7683/IOriginSettler.sol'; + +import {IOriginSettler} from 'src/interfaces/ERC7683/IOriginSettler.sol'; +import {IERC7683Allocator} from 'src/interfaces/IERC7683Allocator.sol'; + +/// @title ERC7683AllocatorLib +/// @notice Library for ERC7683 allocator contracts that interact with the Uniswap Tribunal as the destination settler. +/// @custom:security-contact security@uniswap.org +library ERC7683AllocatorLib { + /// @notice The typehash of the OrderDataOnChain struct + // keccak256("OrderDataOnChain(Order order,uint32 expires) + // Mandate(address adjuster,Mandate_Fill[] fills) + // Mandate_BatchCompact(address arbiter,address sponsor,uint256 nonce,uint256 expires,Mandate_Lock[] commitments) + // Mandate_Fill(uint256 chainId,address tribunal,uint256 expires,address fillToken,uint256 minimumFillAmount,uint256 baselinePriorityFee,uint256 scalingFactor,uint256[] priceCurve,address recipient,Mandate_RecipientCallback[] recipientCallback,bytes32 salt) + // Mandate_Lock(bytes12 lockTag,address token,uint256 amount) + // Mandate_RecipientCallback(uint256 chainId,Mandate_BatchCompact compact,bytes context) + // Order(address arbiter,Lock[] commitments,Mandate mandate)") + bytes32 public constant ORDERDATA_ONCHAIN_TYPEHASH = + 0x66007c95a1c1d0004397bbd6448c24b58a18d5d17e75321cdd119aa3b879d98e; + + /// @notice The typehash of the OrderDataGasless struct + // keccak256("OrderDataGasless(Order order,bool deposit) + // Mandate(address adjuster,Mandate_Fill[] fills) + // Mandate_BatchCompact(address arbiter,address sponsor,uint256 nonce,uint256 expires,Mandate_Lock[] commitments) + // Mandate_Fill(uint256 chainId,address tribunal,uint256 expires,address fillToken,uint256 minimumFillAmount,uint256 baselinePriorityFee,uint256 scalingFactor,uint256[] priceCurve,address recipient,Mandate_RecipientCallback[] recipientCallback,bytes32 salt) + // Mandate_Lock(bytes12 lockTag,address token,uint256 amount) + // Mandate_RecipientCallback(uint256 chainId,Mandate_BatchCompact compact,bytes context) + // Order(address arbiter,Lock[] commitments,Mandate mandate)") + bytes32 public constant ORDERDATA_GASLESS_TYPEHASH = + 0xca948fccb29bd545dea3361927ce0b7d8b680a214439e24af475831131515b4c; + + error InvalidRecipientCallbackLength(); + error InvalidOrderDataType(bytes32 orderDataType, bytes32 expectedOrderDataType); + error InvalidOriginSettler(address originSettler, address expectedOriginSettler); + error InvalidOrderData(bytes orderData); + + /// @notice Checks and decodes the order data for a gasless cross-chain order. + /// @param order The gasless cross-chain order used to prepare the data. + /// @param sponsorSignature The sponsor signature of the order. + /// @return orderData The decoded order data. + /// @return deposit The deposit of the order. + /// @return mandateHash The mandate hash of the order. + /// @return resolvedOrder The resolved order with the fill instructions. + function openForPreparation(IOriginSettler.GaslessCrossChainOrder calldata order, bytes calldata sponsorSignature) + internal + view + returns ( + IERC7683Allocator.Order calldata orderData, + uint32 deposit, + bytes32 mandateHash, + IOriginSettler.ResolvedCrossChainOrder memory resolvedOrder + ) + { + // Check if orderDataType is the one expected by the allocator + if (order.orderDataType != ORDERDATA_GASLESS_TYPEHASH) { + revert InvalidOrderDataType(order.orderDataType, ORDERDATA_GASLESS_TYPEHASH); + } + // Check if the originSettler is the allocator + if (order.originSettler != address(this)) { + revert InvalidOriginSettler(order.originSettler, address(this)); + } + + // Decode the orderData + (orderData, deposit) = decodeOrderData(order.orderData); + deposit = sanitizeBool(deposit); + + // Ensure a valid mandate is provided + if (orderData.mandate.fills.length == 0 || order.fillDeadline != orderData.mandate.fills[0].expires) { + revert InvalidOrderData(order.orderData); + } + bytes32[] memory fillHashes; + (mandateHash, fillHashes) = hashMandate(orderData.mandate); + resolvedOrder = + resolveOrder(order.user, order.nonce, order.openDeadline, fillHashes, orderData, sponsorSignature); + } + + /// @notice Checks and decodes the order data for an on-chain cross-chain order. + /// @param order The on-chain cross-chain order used to prepare the data. + /// @return orderData The decoded order data. + /// @return expires The expiration of the order. + /// @return mandateHash The mandate hash of the order. + /// @return fillHashes The fill hashes of the order. + function openPreparation(IOriginSettler.OnchainCrossChainOrder calldata order) + internal + pure + returns ( + IERC7683Allocator.Order calldata orderData, + uint32 expires, + bytes32 mandateHash, + bytes32[] memory fillHashes + ) + { + // Check if orderDataType is the one expected by the allocator + if (order.orderDataType != ORDERDATA_ONCHAIN_TYPEHASH) { + revert InvalidOrderDataType(order.orderDataType, ORDERDATA_ONCHAIN_TYPEHASH); + } + + // Decode the orderData + (orderData, expires) = decodeOrderData(order.orderData); + expires = sanitizeUint32(expires); + + // Ensure a valid mandate is provided + if (orderData.mandate.fills.length == 0 || order.fillDeadline != orderData.mandate.fills[0].expires) { + revert InvalidOrderData(order.orderData); + } + + (mandateHash, fillHashes) = hashMandate(orderData.mandate); + } + + /// @notice Decodes the order data for an on-chain or gasless cross-chain order. + /// @param orderData The order data to decode. + /// @return order The decoded order. + /// @return additionalInput The additional input of the order, either the expiration or the deposit. + function decodeOrderData(bytes calldata orderData) + internal + pure + returns (IERC7683Allocator.Order calldata order, uint32 additionalInput) + { + // orderData includes the OrderData(OnChain/Gasless) struct, and the nested Order struct. + // 0x00: OrderDataOnChain.offset + // 0x20: OrderDataOnChain.order.offset + // 0x40: OrderDataOnChain.expires + + // 0x00: OrderDataGasless.offset + // 0x20: OrderDataGasless.order.offset + // 0x40: OrderDataGasless.deposit + + assembly ("memory-safe") { + let l := sub(orderData.length, 0x20) + let s := calldataload(add(orderData.offset, 0x20)) // Relative offset of `orderBytes` from `orderData.offset` and the `OrderData...` struct. + order := add(orderData.offset, add(s, 0x20)) // Add 0x20 since the OrderStruct is within the `OrderData...` struct + if shr(64, or(s, or(l, orderData.offset))) { revert(l, 0x00) } + + additionalInput := calldataload(add(orderData.offset, 0x40)) + } + } + + /// @notice Resolves the order into the ERC7683 standard format. + /// @param sponsor The sponsor of the order. + /// @param nonce The nonce of the order. + /// @param expires The expiration of the order. + /// @param fillHashes The fill hashes of the orders Mandate. + /// @param orderData The decoded order data of either the OnChain or Gasless order. + /// @param sponsorSignature The sponsor signature of the order proving their intention to execute the order. + /// @return resolvedOrder The resolved order with the fill instructions. + function resolveOrder( + address sponsor, + uint256 nonce, + uint32 expires, + bytes32[] memory fillHashes, + IERC7683Allocator.Order calldata orderData, + bytes calldata sponsorSignature + ) internal view returns (IOriginSettler.ResolvedCrossChainOrder memory) { + Fill memory mainFill = orderData.mandate.fills[0]; + + IOriginSettler.ResolvedCrossChainOrder memory resolvedOrder = IOriginSettler.ResolvedCrossChainOrder({ + user: sponsor, + originChainId: block.chainid, + openDeadline: expires, + fillDeadline: uint32(mainFill.expires), + orderId: bytes32(nonce), + maxSpent: new IERC7683Allocator.Output[](0), + minReceived: new IERC7683Allocator.Output[](0), + fillInstructions: new IERC7683Allocator.FillInstruction[](0) + }); + + BatchCompact memory compact = BatchCompact({ + arbiter: orderData.arbiter, + sponsor: sponsor, + nonce: nonce, + expires: expires, + commitments: orderData.commitments + }); + + Tribunal.BatchClaim memory claim = Tribunal.BatchClaim({ + chainId: block.chainid, + compact: compact, + sponsorSignature: sponsorSignature, + allocatorSignature: '' // No signature required from this allocator, it will verify the claim via the compacts `authorizeClaim` callback. + }); + + IOriginSettler.FillInstruction[] memory fillInstructions = new IOriginSettler.FillInstruction[](1); + fillInstructions[0] = IOriginSettler.FillInstruction({ + destinationChainId: mainFill.chainId, + destinationSettler: addressToBytes32(mainFill.tribunal), + originData: abi.encode(claim, mainFill, orderData.mandate.adjuster, fillHashes) + }); + resolvedOrder.fillInstructions = fillInstructions; + + IOriginSettler.Output memory spent = IOriginSettler.Output({ + token: addressToBytes32(mainFill.fillToken), + amount: type(uint256).max, + recipient: addressToBytes32(mainFill.recipient), + chainId: mainFill.chainId + }); + IOriginSettler.Output[] memory maxSpent = new IOriginSettler.Output[](1); + maxSpent[0] = spent; + resolvedOrder.maxSpent = maxSpent; + + resolvedOrder.minReceived = createMinimumReceived(orderData.commitments); + + return resolvedOrder; + } + + /// @notice Creates the minimum received Output array for an order. + /// @param commitments The sponsor's commitments of the order. + /// @return minReceived The minimum received Output array for the order. + function createMinimumReceived(Lock[] calldata commitments) + internal + view + returns (IOriginSettler.Output[] memory) + { + IOriginSettler.Output[] memory minReceived = new IOriginSettler.Output[](commitments.length); + + for (uint256 i = 0; i < commitments.length; i++) { + IOriginSettler.Output memory received = IOriginSettler.Output({ + token: addressToBytes32(commitments[i].token), + amount: commitments[i].amount, + recipient: bytes32(0), // Leave empty since these tokens will be received by the filler + chainId: block.chainid + }); + minReceived[i] = received; + } + return minReceived; + } + + /// @notice Hashes the mandate of the order. + /// @param mandate The mandate of the order. + /// @return mandateHash The hash of the full mandate. + /// @return fillHashes The hashes of the fills within the mandate. + function hashMandate(Mandate calldata mandate) internal pure returns (bytes32, bytes32[] memory fillHashes) { + fillHashes = new bytes32[](mandate.fills.length); + for (uint256 i = 0; i < mandate.fills.length; i++) { + fillHashes[i] = hashFill(mandate.fills[i]); + } + return ( + keccak256(abi.encode(MANDATE_TYPEHASH, mandate.adjuster, keccak256(abi.encodePacked(fillHashes)))), + fillHashes + ); + } + + /// @notice Hashes a fill of the mandate. + function hashFill(Fill calldata fill) internal pure returns (bytes32) { + bytes32 priceCurveHash = keccak256(abi.encodePacked(fill.priceCurve)); + return keccak256( + abi.encode( + MANDATE_FILL_TYPEHASH, + fill.chainId, + fill.tribunal, + fill.expires, + fill.fillToken, + fill.minimumFillAmount, + fill.baselinePriorityFee, + fill.scalingFactor, + priceCurveHash, + fill.recipient, + hashRecipientCallback(fill.recipientCallback), + fill.salt + ) + ); + } + + /// @notice Hashes a recipient callback of the fill. + function hashRecipientCallback(RecipientCallback[] calldata recipientCallback) internal pure returns (bytes32) { + if (recipientCallback.length == 0) { + // empty hash + return 0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470; + } else if (recipientCallback.length != 1) { + revert InvalidRecipientCallbackLength(); + } + + RecipientCallback calldata callback = recipientCallback[0]; + + return keccak256( + abi.encodePacked( + keccak256( + abi.encode( + MANDATE_RECIPIENT_CALLBACK_TYPEHASH, + callback.chainId, + AL.getClaimHash( + callback.compact.arbiter, + callback.compact.sponsor, + callback.compact.nonce, + callback.compact.expires, + AL.getCommitmentsHash(callback.compact.commitments, MANDATE_LOCK_TYPEHASH), + callback.mandateHash, + MANDATE_BATCH_COMPACT_TYPEHASH + ), + callback.context + ) + ) + ) + ); + } + + function addressToBytes32(address input) internal pure returns (bytes32 output) { + assembly ("memory-safe") { + output := shr(96, shl(96, input)) + } + } + + function sanitizeUint32(uint32 value) internal pure returns (uint32) { + assembly ("memory-safe") { + value := shr(224, shl(224, value)) + } + return value; + } + + function sanitizeBool(uint32 value) internal pure returns (uint32) { + assembly ("memory-safe") { + value := iszero(iszero(value)) + } + return value; + } +} diff --git a/src/allocators/lib/TypeHashes.sol b/src/allocators/lib/TypeHashes.sol new file mode 100644 index 0000000..ba30237 --- /dev/null +++ b/src/allocators/lib/TypeHashes.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +// keccak256("Mandate(uint256 chainId,address tribunal,address recipient,uint256 expires,address token,uint256 minimumAmount,uint256 baselinePriorityFee,uint256 scalingFactor,uint256[] decayCurve,bytes32 salt)") +bytes32 constant MANDATE_TYPEHASH = 0x74d9c10530859952346f3e046aa2981a24bb7524b8394eb45a9deddced9d6501; + +// keccak256("BatchCompact(address arbiter,address sponsor,uint256 nonce,uint256 expires,Lock[] commitments,Mandate mandate) +// Lock(bytes12 lockTag,address token,uint256 amount) +// Mandate(uint256 chainId,address tribunal,address recipient,uint256 expires,address token,uint256 minimumAmount,uint256 baselinePriorityFee,uint256 scalingFactor,uint256[] decayCurve,bytes32 salt)") +bytes32 constant BATCH_COMPACT_WITNESS_TYPEHASH = 0x5ede122c736b60a8b718f83dcfb5d6e4aa27c9714d0c7bc9ca86562b8f878463; + +// keccak256("Compact(address arbiter,address sponsor,uint256 nonce,uint256 expires,bytes12 lockTag,address token,uint256 amount,Mandate mandate) +// Mandate(uint256 chainId,address tribunal,address recipient,uint256 expires,address token,uint256 minimumAmount,uint256 baselinePriorityFee,uint256 scalingFactor,uint256[] decayCurve,bytes32 salt)") +bytes32 constant COMPACT_WITNESS_TYPEHASH = 0x2ec0d30491bb66a6eb554b9d53f490d79b54fc5f4963bed4b2bb8096b4790f1f; diff --git a/src/interfaces/ERC7683/IOriginSettler.sol b/src/interfaces/ERC7683/IOriginSettler.sol new file mode 100644 index 0000000..026ae91 --- /dev/null +++ b/src/interfaces/ERC7683/IOriginSettler.sol @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +/// @title IOriginSettler +/// @notice Standard interface for settlement contracts on the origin chain +interface IOriginSettler { + /// @title GaslessCrossChainOrder CrossChainOrder type + /// @notice Standard order struct to be signed by users, disseminated to fillers, and submitted to origin settler contracts + struct GaslessCrossChainOrder { + /// @dev The contract address that the order is meant to be settled by. + /// Fillers send this order to this contract address on the origin chain + address originSettler; + /// @dev The address of the user who is initiating the swap, + /// whose input tokens will be taken and escrowed + address user; + /// @dev Nonce to be used as replay protection for the order + uint256 nonce; + /// @dev The chainId of the origin chain + uint256 originChainId; + /// @dev The timestamp by which the order must be opened + uint32 openDeadline; + /// @dev The timestamp by which the order must be filled on the destination chain + uint32 fillDeadline; + /// @dev Type identifier for the order data. This is an EIP-712 typehash. + bytes32 orderDataType; + /// @dev Arbitrary implementation-specific data + /// Can be used to define tokens, amounts, destination chains, fees, settlement parameters, + /// or any other order-type specific information + bytes orderData; + } + + /// @title OnchainCrossChainOrder CrossChainOrder type + /// @notice Standard order struct for user-opened orders, where the user is the msg.sender. + struct OnchainCrossChainOrder { + /// @dev The timestamp by which the order must be filled on the destination chain + uint32 fillDeadline; + /// @dev Type identifier for the order data. This is an EIP-712 typehash. + bytes32 orderDataType; + /// @dev Arbitrary implementation-specific data + /// Can be used to define tokens, amounts, destination chains, fees, settlement parameters, + /// or any other order-type specific information + bytes orderData; + } + /// @title ResolvedCrossChainOrder type + /// @notice An implementation-generic representation of an order intended for filler consumption + /// @dev Defines all requirements for filling an order by unbundling the implementation-specific orderData. + /// @dev Intended to improve integration generalization by allowing fillers to compute the exact input and output information of any order + + struct ResolvedCrossChainOrder { + /// @dev The address of the user who is initiating the transfer + address user; + /// @dev The chainId of the origin chain + uint256 originChainId; + /// @dev The timestamp by which the order must be opened + uint32 openDeadline; + /// @dev The timestamp by which the order must be filled on the destination chain(s) + uint32 fillDeadline; + /// @dev The unique identifier for this order within this settlement system + bytes32 orderId; + /// @dev The max outputs that the filler will send. It's possible the actual amount depends on the state of the destination + /// chain (destination dutch auction, for instance), so these outputs should be considered a cap on filler liabilities. + Output[] maxSpent; + /// @dev The minimum outputs that must be given to the filler as part of order settlement. Similar to maxSpent, it's possible + /// that special order types may not be able to guarantee the exact amount at open time, so this should be considered + /// a floor on filler receipts. + Output[] minReceived; + /// @dev Each instruction in this array is parameterizes a single leg of the fill. This provides the filler with the information + /// necessary to perform the fill on the destination(s). + FillInstruction[] fillInstructions; + } + + /// @notice Tokens that must be received for a valid order fulfillment + struct Output { + /// @dev The address of the ERC20 token on the destination chain + /// @dev address(0) used as a sentinel for the native token + bytes32 token; + /// @dev The amount of the token to be sent + uint256 amount; + /// @dev The address to receive the output tokens + bytes32 recipient; + /// @dev The destination chain for this output + uint256 chainId; + } + + /// @title FillInstruction type + /// @notice Instructions to parameterize each leg of the fill + /// @dev Provides all the origin-generated information required to produce a valid fill leg + struct FillInstruction { + /// @dev The contract address that the order is meant to be settled by + uint256 destinationChainId; + /// @dev The contract address that the order is meant to be filled on + bytes32 destinationSettler; + /// @dev The data generated on the origin chain needed by the destinationSettler to process the fill + bytes originData; + } + + /// @notice Signals that an order has been opened + /// @param orderId a unique order identifier within this settlement system + /// @param resolvedOrder resolved order that would be returned by resolve if called instead of Open + event Open(bytes32 indexed orderId, ResolvedCrossChainOrder resolvedOrder); + + /// @notice Opens a gasless cross-chain order on behalf of a user. + /// @dev To be called by the filler. + /// @dev This method must emit the Open event + /// @param order The GaslessCrossChainOrder definition + /// @param signature The user's signature over the order + /// @param originFillerData Any filler-defined data required by the settler + function openFor(GaslessCrossChainOrder calldata order, bytes calldata signature, bytes calldata originFillerData) + external; + + /// @notice Opens a cross-chain order + /// @dev To be called by the user + /// @dev This method must emit the Open event + /// @param order The OnchainCrossChainOrder definition + function open(OnchainCrossChainOrder calldata order) external; + + /// @notice Resolves a specific GaslessCrossChainOrder into a generic ResolvedCrossChainOrder + /// @dev Intended to improve standardized integration of various order types and settlement contracts + /// @param order The GaslessCrossChainOrder definition + /// @param originFillerData Any filler-defined data required by the settler + /// @return ResolvedCrossChainOrder hydrated order data including the inputs and outputs of the order + function resolveFor(GaslessCrossChainOrder calldata order, bytes calldata originFillerData) + external + view + returns (ResolvedCrossChainOrder memory); + + /// @notice Resolves a specific OnchainCrossChainOrder into a generic ResolvedCrossChainOrder + /// @dev Intended to improve standardized integration of various order types and settlement contracts + /// @param order The OnchainCrossChainOrder definition + /// @return ResolvedCrossChainOrder hydrated order data including the inputs and outputs of the order + function resolve(OnchainCrossChainOrder calldata order) external view returns (ResolvedCrossChainOrder memory); +} diff --git a/src/interfaces/IERC7683Allocator.sol b/src/interfaces/IERC7683Allocator.sol new file mode 100644 index 0000000..794b44c --- /dev/null +++ b/src/interfaces/IERC7683Allocator.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {IOriginSettler} from './ERC7683/IOriginSettler.sol'; +import {IAllocator} from '@uniswap/the-compact/interfaces/IAllocator.sol'; +import {Lock} from '@uniswap/the-compact/types/EIP712Types.sol'; +import {Adjustment, Fill, Mandate, RecipientCallback} from '@uniswap/tribunal/types/TribunalStructs.sol'; + +interface IERC7683Allocator is IOriginSettler, IAllocator { + struct OrderDataOnChain { + Order order; // The remaining BatchCompact and Mandate data + uint32 expires; // COMPACT - The time at which the claim expires and the user is able to withdraw their funds. + } + + struct OrderDataGasless { + Order order; // The remaining BatchCompact and Mandate data + bool deposit; // Weather the order includes a deposit of the relevant tokens. This allows to skip a sponsor confirmation + } + + /// @dev The data that OnChain and Gasless orders have in common + struct Order { + address arbiter; // COMPACT - The account tasked with verifying and submitting the claim. + Lock[] commitments; // COMPACT - The token IDs and amounts to allocate. + Mandate mandate; // MANDATE - Mandate struct fom tribunal + } + + error InvalidOriginSettler(address originSettler, address expectedOriginSettler); + error InvalidOrderDataType(bytes32 orderDataType, bytes32 expectedOrderDataType); + error InvalidNonce(uint256 nonce, uint256 expectedNonce); + error InvalidAllocatorData(bytes32 expectedAllocatorData, bytes32 actualAllocatorData); + error UnsupportedToken(address token); + error InvalidOrderData(bytes orderData); + error InvalidRecipientCallbackLength(); + + /// @notice Returns the type string of the compact including the witness + function getCompactWitnessTypeString() external pure returns (string memory); + + /// @notice Returns the nonce for a given order and caller + /// @dev The nonce is the most significant 96 bits. The least significant 160 bits must be the sponsor address + function getNonce(GaslessCrossChainOrder calldata order_, address caller) external view returns (uint256 nonce); + + /// @notice Creates the filler data for the open event to be used on the IDestinationSettler + /// @param claimant_ The address claiming the origin tokens after a successful fill (typically the address of the filler) + function createFillerData(address claimant_) external pure returns (bytes memory fillerData); +} diff --git a/src/interfaces/IHybridAllocator.sol b/src/interfaces/IHybridAllocator.sol new file mode 100644 index 0000000..34053bd --- /dev/null +++ b/src/interfaces/IHybridAllocator.sol @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import {IOnChainAllocation} from '@uniswap/the-compact/interfaces/IOnChainAllocation.sol'; + +interface IHybridAllocator is IOnChainAllocation { + error InvalidAllocatorRegistration(address alreadyRegisteredAllocator); + error Unsupported(); + error InvalidIds(); + error InvalidAllocatorId(uint96 allocatorId, uint96 expectedAllocatorId); + error InvalidCaller(address sender, address expectedSender); + error InvalidSignature(); + error InvalidSigner(); + error LastSigner(); + error InvalidValue(uint256 value, uint256 expectedValue); + + /** + * @notice Add an offchain signer to the allocator. + * @param signer_ The address of the signer to add. + */ + function addSigner(address signer_) external; + + /** + * @notice Remove an offchain signer from the allocator. + * @dev The last signer cannot be removed. + * @param signer_ The address of the signer to remove. + */ + function removeSigner(address signer_) external; + + /** + * @notice Replace an offchain signer with a new one. + * @dev The caller must be the replaced signer. + * @param newSigner_ The address of the new signer. + */ + function replaceSigner(address newSigner_) external; + + /** + * @notice Create an allocation and a registration on the compact by depositing the relevant tokens to the compact. + * @dev If the provided amounts are zero, the contract will use its own token balance. + * @param recipient The address receiving the deposited tokens and the sponsor of the compact. + * @param idsAndAmounts The IDs and amounts of the tokens to register. Amounts can be zero. + * @param arbiter The address of the arbiter for the compact. + * @param expires The expiration time of the compact. + * @param typehash The typehash of the compact. + * @param witness The witness of the compact. + * @return The claim hash, the registered amounts, and the nonce. + */ + function allocateAndRegister( + address recipient, + uint256[2][] memory idsAndAmounts, + address arbiter, + uint256 expires, + bytes32 typehash, + bytes32 witness + ) external payable returns (bytes32, uint256[] memory, uint256); +} diff --git a/src/interfaces/IOnChainAllocator.sol b/src/interfaces/IOnChainAllocator.sol new file mode 100644 index 0000000..2d43f60 --- /dev/null +++ b/src/interfaces/IOnChainAllocator.sol @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {IOnChainAllocation} from '@uniswap/the-compact/interfaces/IOnChainAllocation.sol'; +import {Lock} from '@uniswap/the-compact/types/EIP712Types.sol'; + +interface IOnChainAllocator is IOnChainAllocation { + struct Allocation { + uint32 expires; + uint224 amount; + bytes32 claimHash; + } + + /// @notice Thrown if the allocator is not successfully registered + error InvalidAllocatorRegistration(address alreadyRegisteredAllocator); + + /// @notice Thrown if a claim is already active + error ClaimActive(address sponsor); + + /// @notice Thrown if the caller is invalid + error InvalidCaller(address caller, address expected); + + /// @notice Thrown if the nonce has already been consumed on the compact contract + error NonceAlreadyInUse(uint256 nonce); + + /// @notice Thrown if the sponsor does not have enough balance to lock the amount + error InsufficientBalance(address sponsor, uint256 id, uint256 availableBalance, uint256 expectedBalance); + + /// @notice Thrown if the provided expiration is not valid + error InvalidExpiration(uint256 expires, uint256 expectedExpiration); + + /// @notice Thrown if the expiration is longer then the tokens forced withdrawal time + error ForceWithdrawalAvailable(uint256 expires, uint256 forcedWithdrawalExpiration); + + /// @notice Thrown if the allocator is not the one expected + error InvalidAllocator(uint96 allocatorId, uint96 expectedAllocatorId); + + /// @notice Thrown if the provided lock is not available or expired + error InvalidClaim(bytes32 claimHash); + + /// @notice Thrown if the current allocation is bigger then uint224 + /// @dev Allocations above uint224 do not support attestations + error ExtensiveAllocationActive(address sponsor, uint256 id); + + /// @notice Thrown if the provided amount is not valid + error InvalidAmount(uint256 amount); + + /// @notice Thrown if the provided signature is invalid + error InvalidSignature(address signer, address expectedSigner); + + /// @notice Registers an allocation for a set of tokens + /// @param commitments The commitments of the allocations + /// @param arbiter The arbiter of the allocation + /// @param expires The expiration of the allocation + /// @param typehash The typehash of the allocation + /// @param witness The witness of the allocation + function allocate(Lock[] memory commitments, address arbiter, uint32 expires, bytes32 typehash, bytes32 witness) + external + returns (bytes32 claimHash, uint256 claimNonce); + + /// @notice Registers an allocation for a set of tokens on behalf of a sponsor + /// @param sponsor The address of the sponsor + /// @param commitments The commitments of the allocations + /// @param arbiter The arbiter of the allocation + /// @param expires The expiration of the allocation + /// @param typehash The typehash of the allocation + /// @param witness The witness of the allocation + /// @param signature The signature of the allocation + function allocateFor( + address sponsor, + Lock[] memory commitments, + address arbiter, + uint32 expires, + bytes32 typehash, + bytes32 witness, + bytes calldata signature + ) external returns (bytes32 claimHash, uint256 claimNonce); + + /// @notice Registers an allocation for a set of tokens on behalf of a recipient + /// @dev The msg.sender needs to provide the tokens of the registered allocation + /// @param recipient The recipient of the allocation + /// @param commitments The commitments of the allocations + /// @param arbiter The arbiter of the allocation + /// @param expires The expiration of the allocation + /// @param typehash The typehash of the allocation + /// @param witness The witness of the allocation + function allocateAndRegister( + address recipient, + Lock[] calldata commitments, + address arbiter, + uint32 expires, + bytes32 typehash, + bytes32 witness + ) external returns (bytes32 claimHash, uint256[] memory registeredAmounts, uint256 nonce); +} diff --git a/src/test/ERC20Mock.sol b/src/test/ERC20Mock.sol new file mode 100644 index 0000000..f8a0ce1 --- /dev/null +++ b/src/test/ERC20Mock.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import {ERC20} from '@openzeppelin/contracts/token/ERC20/ERC20.sol'; + +contract ERC20Mock is ERC20 { + constructor(string memory name_, string memory symbol_) ERC20(name_, symbol_) {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} diff --git a/src/test/OnChainAllocationCaller.sol b/src/test/OnChainAllocationCaller.sol new file mode 100644 index 0000000..1db8bff --- /dev/null +++ b/src/test/OnChainAllocationCaller.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import {IOnChainAllocation} from '@uniswap/the-compact/interfaces/IOnChainAllocation.sol'; +import {ITheCompact} from '@uniswap/the-compact/interfaces/ITheCompact.sol'; + +contract OnChainAllocationCaller { + IOnChainAllocation public immutable ALLOCATOR; + ITheCompact public immutable COMPACT; + + constructor(address allocator_, address compact_) { + ALLOCATOR = IOnChainAllocation(allocator_); + COMPACT = ITheCompact(compact_); + } + + function onChainAllocation( + address recipient, + uint256[2][] calldata idsAndAmounts, + address arbiter, + uint256 expires, + bytes32 typehash, + bytes32 witness, + uint8 todo + ) external { + uint256 nonce; + if (todo == 0) { + // Correctly deposit and register + nonce = ALLOCATOR.prepareAllocation(recipient, idsAndAmounts, arbiter, expires, typehash, witness, ''); + ITheCompact(COMPACT).batchDepositAndRegisterFor( + recipient, idsAndAmounts, arbiter, nonce, expires, typehash, witness + ); + } else if (todo == 1) { + // Only deposit, do not register + nonce = ALLOCATOR.prepareAllocation(recipient, idsAndAmounts, arbiter, expires, typehash, witness, ''); + ITheCompact(COMPACT).batchDeposit(idsAndAmounts, recipient); + } else if (todo == 2) { + // Do not prepare, but deposit and register + ITheCompact(COMPACT).batchDepositAndRegisterFor( + recipient, idsAndAmounts, arbiter, nonce, expires, typehash, witness + ); + } else if (todo == 3) { + nonce = ALLOCATOR.prepareAllocation(recipient, idsAndAmounts, arbiter, expires, typehash, witness, ''); + } else { + // Correctly deposit and register + nonce = ALLOCATOR.prepareAllocation(recipient, idsAndAmounts, arbiter, expires, typehash, witness, ''); + ITheCompact(COMPACT).batchDepositAndRegisterFor( + recipient, idsAndAmounts, arbiter, nonce, expires, typehash, witness + ); + ALLOCATOR.executeAllocation(recipient, idsAndAmounts, arbiter, expires, typehash, witness, ''); + } + ALLOCATOR.executeAllocation(recipient, idsAndAmounts, arbiter, expires, typehash, witness, ''); + } +} diff --git a/test/ERC7683Allocator.t.sol b/test/ERC7683Allocator.t.sol new file mode 100644 index 0000000..f71de5b --- /dev/null +++ b/test/ERC7683Allocator.t.sol @@ -0,0 +1,1037 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {IERC1271} from '@openzeppelin/contracts/interfaces/IERC1271.sol'; +import {ERC6909} from '@solady/tokens/ERC6909.sol'; +import {TheCompact} from '@uniswap/the-compact/TheCompact.sol'; +import {ITheCompact} from '@uniswap/the-compact/interfaces/ITheCompact.sol'; + +import {IdLib} from '@uniswap/the-compact/lib/IdLib.sol'; + +import {BatchClaim} from '@uniswap/the-compact/types/BatchClaims.sol'; +import {BatchClaimComponent, Component} from '@uniswap/the-compact/types/Components.sol'; +import {BatchCompact, COMPACT_TYPEHASH, LOCK_TYPEHASH, Lock} from '@uniswap/the-compact/types/EIP712Types.sol'; +import {ForcedWithdrawalStatus} from '@uniswap/the-compact/types/ForcedWithdrawalStatus.sol'; +import {ResetPeriod} from '@uniswap/the-compact/types/ResetPeriod.sol'; +import {Scope} from '@uniswap/the-compact/types/Scope.sol'; +import {Test} from 'forge-std/Test.sol'; + +import {TestHelper} from 'test/util/TestHelper.sol'; + +import {IOnChainAllocation} from '@uniswap/the-compact/interfaces/IOnChainAllocation.sol'; + +import {Tribunal} from '@uniswap/tribunal/Tribunal.sol'; +import {Fill, Mandate, RecipientCallback} from '@uniswap/tribunal/types/TribunalStructs.sol'; +import { + COMPACT_TYPEHASH_WITH_MANDATE, + MANDATE_BATCH_COMPACT_TYPEHASH, + MANDATE_FILL_TYPEHASH, + MANDATE_RECIPIENT_CALLBACK_TYPEHASH, + MANDATE_TYPEHASH, + WITNESS_TYPESTRING as WITNESS_TYPESTRING_TRIBUNAL +} from '@uniswap/tribunal/types/TribunalTypeHashes.sol'; +import {ERC7683Allocator} from 'src/allocators/ERC7683Allocator.sol'; +import {ERC7683AllocatorLib as ERC7683AL} from 'src/allocators/lib/ERC7683AllocatorLib.sol'; +import {IOriginSettler} from 'src/interfaces/ERC7683/IOriginSettler.sol'; +import {IERC7683Allocator} from 'src/interfaces/IERC7683Allocator.sol'; +import {IOnChainAllocator} from 'src/interfaces/IOnChainAllocator.sol'; + +import {ERC20Mock} from 'src/test/ERC20Mock.sol'; + +import { + CompactData, + GaslessCrossChainOrderData, + MocksSetup, + OnChainCrossChainOrderData +} from 'test/util/ERC7683TestHelper.sol'; + +contract MockAllocator is GaslessCrossChainOrderData, OnChainCrossChainOrderData { + ERC7683Allocator erc7683Allocator; + + function setUp() public virtual override(GaslessCrossChainOrderData, OnChainCrossChainOrderData) { + TheCompact compactContract_ = new TheCompact(); + erc7683Allocator = new ERC7683Allocator(address(compactContract_)); + _setUp(address(erc7683Allocator), compactContract_, _composeNonceUint(user, 1)); + super.setUp(); + } +} + +contract ERC7683Allocator_open is MockAllocator { + function test_revert_InvalidOrderDataType() public { + // Order data type is invalid + bytes32 falseOrderDataType = keccak256('false'); + IOriginSettler.OnchainCrossChainOrder memory onChainCrossChainOrder_ = _getOnChainCrossChainOrder(); + onChainCrossChainOrder_.orderDataType = falseOrderDataType; + + vm.prank(user); + vm.expectRevert( + abi.encodeWithSelector( + IERC7683Allocator.InvalidOrderDataType.selector, falseOrderDataType, ORDERDATA_ONCHAIN_TYPEHASH + ) + ); + erc7683Allocator.open(onChainCrossChainOrder_); + } + + // Removed redundant typehash equality check; we compare against library in setup. + + function test_revert_ManipulatedOrderData() public { + // Deposit tokens + vm.startPrank(user); + compactContract.depositERC20(address(usdc), usdcLockTag, defaultAmount, user); + + // register a claim + BatchCompact memory compact_ = _getCompact(); + Mandate memory mandate_ = _getMandate(); + + (bytes32 mandateHash,) = _hashMandate(mandate_); + bytes32 claimHash = _deriveClaimHash(compact_, mandateHash); + compactContract.register(claimHash, COMPACT_TYPEHASH_WITH_MANDATE); + + vm.stopPrank(); + + (IOriginSettler.OnchainCrossChainOrder memory onChainCrossChainOrder_) = _getOnChainCrossChainOrder(); + + // Manipulate the order data + uint256 outOfBounds = type(uint256).max; // uint256(type(uint64).max) + 1; + + bytes memory callData = abi.encodeWithSelector(IOriginSettler.open.selector, onChainCrossChainOrder_); + // 0x00 selector + // 0x24 OnchainCrossChainOrder.offset + // 0x44 OnchainCrossChainOrder.fillDeadline + // 0x64 OnchainCrossChainOrder.orderDataType + // 0x84 OnchainCrossChainOrder.orderData.offset + // 0xa4 OnchainCrossChainOrder.orderData.length + // 0xc4 OnchainCrossChainOrder.OrderDataOnChain.offset + // 0xe4 OnchainCrossChainOrder.OrderDataOnChain.Order.offset + + assembly ("memory-safe") { + mstore(add(callData, 0xe4), outOfBounds) + } + + vm.prank(user); + (bool success, bytes memory returnData) = address(erc7683Allocator).call(callData); + assertEq(success, false); + assertEq(returnData.length, 0); + } + + function test_revert_InvalidRegistration() public { + // we deposit tokens + vm.startPrank(user); + compactContract.depositERC20(address(usdc), usdcLockTag, defaultAmount, user); + + // we do NOT register a claim + + vm.stopPrank(); + + BatchCompact memory compact_ = _getCompact(); + Mandate memory mandate_ = _getMandate(); + (bytes32 mandateHash,) = _hashMandate(mandate_); + bytes32 claimHash = _deriveClaimHash(compact_, mandateHash); + (IOriginSettler.OnchainCrossChainOrder memory onChainCrossChainOrder_) = + _getOnChainCrossChainOrder(compact_, mandate_); + + vm.prank(user); + vm.expectRevert(abi.encodeWithSelector(IOnChainAllocation.InvalidRegistration.selector, user, claimHash)); + erc7683Allocator.open(onChainCrossChainOrder_); + } + + function test_successful() public { + // Deposit tokens + vm.startPrank(user); + compactContract.depositERC20(address(usdc), usdcLockTag, defaultAmount, user); + + // register a claim + BatchCompact memory compact_ = _getCompact(); + Mandate memory mandate_ = _getMandate(); + + (bytes32 mHash,) = _hashMandate(mandate_); + bytes32 claimHash = _deriveClaimHash(compact_, mHash); + compactContract.register(claimHash, COMPACT_TYPEHASH_WITH_MANDATE); + + vm.stopPrank(); + + (IOriginSettler.OnchainCrossChainOrder memory onChainCrossChainOrder_) = _getOnChainCrossChainOrder(); + IOriginSettler.Output[] memory maxSpent = new IOriginSettler.Output[](1); + IOriginSettler.Output[] memory minReceived = new IOriginSettler.Output[](1); + IOriginSettler.FillInstruction[] memory fillInstructions = new IOriginSettler.FillInstruction[](1); + maxSpent[0] = IOriginSettler.Output({ + token: bytes32(uint256(uint160(defaultOutputToken))), + amount: type(uint256).max, + recipient: bytes32(uint256(uint160(user))), + chainId: defaultOutputChainId + }); + minReceived[0] = IOriginSettler.Output({ + token: bytes32(uint256(uint160(address(usdc)))), + amount: defaultAmount, + recipient: '', + chainId: block.chainid + }); + Tribunal.BatchClaim memory claim = Tribunal.BatchClaim({ + chainId: block.chainid, + compact: _getCompact(), + sponsorSignature: '', + allocatorSignature: '' + }); + fillInstructions[0] = IOriginSettler.FillInstruction({ + destinationChainId: defaultOutputChainId, + destinationSettler: bytes32(uint256(uint160(tribunal))), + originData: abi.encode(claim, _getMandate().fills[0], adjuster, _buildFillHashes(_getMandate())) + }); + + IOriginSettler.ResolvedCrossChainOrder memory resolvedCrossChainOrder = IOriginSettler.ResolvedCrossChainOrder({ + user: user, + originChainId: block.chainid, + openDeadline: uint32(_getClaimExpiration()), + fillDeadline: uint32(_getFillExpiration()), + orderId: bytes32(defaultNonce), + maxSpent: maxSpent, + minReceived: minReceived, + fillInstructions: fillInstructions + }); + vm.prank(user); + vm.expectEmit(true, false, false, false, address(erc7683Allocator)); + emit IOriginSettler.Open(bytes32(defaultNonce), resolvedCrossChainOrder); + erc7683Allocator.open(onChainCrossChainOrder_); + vm.snapshotGasLastCall('open_simpleOrder'); + } +} + +contract ERC7683Allocator_openFor is MockAllocator { + function test_revert_InvalidOrderDataType() public { + // Order data type is invalid + bytes32 falseOrderDataType = keccak256('false'); + + vm.prank(user); + vm.expectRevert( + abi.encodeWithSelector( + IERC7683Allocator.InvalidOrderDataType.selector, falseOrderDataType, ORDERDATA_GASLESS_TYPEHASH + ) + ); + IOriginSettler.GaslessCrossChainOrder memory falseGaslessCrossChainOrder = _getGaslessCrossChainOrder(); + falseGaslessCrossChainOrder.orderDataType = falseOrderDataType; + erc7683Allocator.openFor(falseGaslessCrossChainOrder, '', ''); + } + + // removed redundant typehash getter check + + function test_revert_InvalidDecoding() public { + // Decoding fails because of additional data + vm.prank(user); + vm.expectRevert(); + IOriginSettler.GaslessCrossChainOrder memory falseGaslessCrossChainOrder = _getGaslessCrossChainOrder(); + falseGaslessCrossChainOrder.orderData = abi.encode(falseGaslessCrossChainOrder.orderData, uint8(1)); + erc7683Allocator.openFor(falseGaslessCrossChainOrder, '', ''); + } + + function test_revert_InvalidOriginSettler() public { + // Origin settler is not the allocator + address falseOriginSettler = makeAddr('falseOriginSettler'); + vm.expectRevert( + abi.encodeWithSelector( + IERC7683Allocator.InvalidOriginSettler.selector, falseOriginSettler, address(erc7683Allocator) + ) + ); + IOriginSettler.GaslessCrossChainOrder memory falseGaslessCrossChainOrder = _getGaslessCrossChainOrder(); + falseGaslessCrossChainOrder.originSettler = falseOriginSettler; + vm.prank(user); + erc7683Allocator.openFor(falseGaslessCrossChainOrder, '', ''); + } + + function test_revert_InvalidNonce(uint256 nonce) public { + vm.assume(nonce != defaultNonce); + + BatchCompact memory compact_ = _getCompact(); + compact_.nonce = nonce; + vm.expectRevert(abi.encodeWithSelector(IERC7683Allocator.InvalidNonce.selector, compact_.nonce, defaultNonce)); + IOriginSettler.GaslessCrossChainOrder memory falseGaslessCrossChainOrder = _getGaslessCrossChainOrder(); + falseGaslessCrossChainOrder.nonce = nonce; + vm.prank(user); + erc7683Allocator.openFor(falseGaslessCrossChainOrder, '', ''); + } + + function test_successful_userHimself() public { + // Deposit tokens + vm.startPrank(user); + compactContract.depositERC20(address(usdc), usdcLockTag, defaultAmount, user); + + BatchCompact memory compact_ = _getCompact(); + Mandate memory mandate_ = _getMandate(); + (bytes32 mandateHash,) = _hashMandate(mandate_); + bytes32 claimHash = _deriveClaimHash(compact_, mandateHash); + + // Register the claim to allow to open the order + compactContract.register(claimHash, COMPACT_TYPEHASH_WITH_MANDATE); + + vm.stopPrank(); + + IOriginSettler.GaslessCrossChainOrder memory gaslessCrossChainOrder_ = + _getGaslessCrossChainOrder(compact_, mandate_, false); + IOriginSettler.Output[] memory maxSpent = new IOriginSettler.Output[](1); + IOriginSettler.Output[] memory minReceived = new IOriginSettler.Output[](1); + IOriginSettler.FillInstruction[] memory fillInstructions = new IOriginSettler.FillInstruction[](1); + maxSpent[0] = IOriginSettler.Output({ + token: bytes32(uint256(uint160(defaultOutputToken))), + amount: type(uint256).max, + recipient: bytes32(uint256(uint160(user))), + chainId: defaultOutputChainId + }); + minReceived[0] = IOriginSettler.Output({ + token: bytes32(uint256(uint160(address(usdc)))), + amount: defaultAmount, + recipient: '', + chainId: block.chainid + }); + Tribunal.BatchClaim memory claim = Tribunal.BatchClaim({ + chainId: block.chainid, + compact: _getCompact(), + sponsorSignature: '', + allocatorSignature: '' + }); + fillInstructions[0] = IOriginSettler.FillInstruction({ + destinationChainId: defaultOutputChainId, + destinationSettler: bytes32(uint256(uint160(tribunal))), + originData: abi.encode(claim, _getMandate(), uint256(0), uint256(0)) + }); + + IOriginSettler.ResolvedCrossChainOrder memory resolvedCrossChainOrder = IOriginSettler.ResolvedCrossChainOrder({ + user: user, + originChainId: block.chainid, + openDeadline: uint32(_getClaimExpiration()), + fillDeadline: uint32(_getFillExpiration()), + orderId: bytes32(defaultNonce), + maxSpent: maxSpent, + minReceived: minReceived, + fillInstructions: fillInstructions + }); + vm.prank(user); + vm.expectEmit(true, false, false, false, address(erc7683Allocator)); + emit IOriginSettler.Open(bytes32(defaultNonce), resolvedCrossChainOrder); + erc7683Allocator.openFor(gaslessCrossChainOrder_, '', ''); + vm.snapshotGasLastCall('openFor_simpleOrder_userHimself'); + } + + function test_successful_relayed_registration(address filler) public { + // Deposit tokens + vm.startPrank(user); + compactContract.depositERC20(address(usdc), usdcLockTag, defaultAmount, user); + + BatchCompact memory compact_ = _getCompact(); + Mandate memory mandate_ = _getMandate(); + (bytes32 mandateHash,) = _hashMandate(mandate_); + bytes32 claimHash = _deriveClaimHash(compact_, mandateHash); + + // Register the claim to allow to open the order + compactContract.register(claimHash, COMPACT_TYPEHASH_WITH_MANDATE); + + vm.stopPrank(); + + IOriginSettler.GaslessCrossChainOrder memory gaslessCrossChainOrder_ = + _getGaslessCrossChainOrder(compact_, mandate_, false); + + IOriginSettler.Output[] memory maxSpent = new IOriginSettler.Output[](1); + IOriginSettler.Output[] memory minReceived = new IOriginSettler.Output[](1); + IOriginSettler.FillInstruction[] memory fillInstructions = new IOriginSettler.FillInstruction[](1); + maxSpent[0] = IOriginSettler.Output({ + token: bytes32(uint256(uint160(defaultOutputToken))), + amount: type(uint256).max, + recipient: bytes32(uint256(uint160(user))), + chainId: defaultOutputChainId + }); + minReceived[0] = IOriginSettler.Output({ + token: bytes32(uint256(uint160(address(usdc)))), + amount: defaultAmount, + recipient: '', + chainId: block.chainid + }); + Tribunal.BatchClaim memory claim = Tribunal.BatchClaim({ + chainId: block.chainid, + compact: _getCompact(), + sponsorSignature: '', + allocatorSignature: '' + }); + fillInstructions[0] = IOriginSettler.FillInstruction({ + destinationChainId: defaultOutputChainId, + destinationSettler: bytes32(uint256(uint160(tribunal))), + originData: abi.encode(claim, _getMandate(), uint256(0), uint256(0)) + }); + + IOriginSettler.ResolvedCrossChainOrder memory resolvedCrossChainOrder = IOriginSettler.ResolvedCrossChainOrder({ + user: user, + originChainId: block.chainid, + openDeadline: uint32(_getClaimExpiration()), + fillDeadline: uint32(_getFillExpiration()), + orderId: bytes32(defaultNonce), + maxSpent: maxSpent, + minReceived: minReceived, + fillInstructions: fillInstructions + }); + vm.prank(filler); + vm.expectEmit(true, false, false, false, address(erc7683Allocator)); + emit IOriginSettler.Open(bytes32(defaultNonce), resolvedCrossChainOrder); + erc7683Allocator.openFor(gaslessCrossChainOrder_, '', ''); + vm.snapshotGasLastCall('openFor_simpleOrder_relayed'); + } + + function test_successful_relayed_signature(address filler) public { + // Deposit tokens + vm.startPrank(user); + compactContract.depositERC20(address(usdc), usdcLockTag, defaultAmount, user); + vm.stopPrank(); + + BatchCompact memory compact_ = _getCompact(); + Mandate memory mandate_ = _getMandate(); + bytes memory sponsorSignature = _hashAndSign(compact_, mandate_, address(compactContract), userPK); + + IOriginSettler.GaslessCrossChainOrder memory gaslessCrossChainOrder_ = + _getGaslessCrossChainOrder(compact_, mandate_, false); + + IOriginSettler.Output[] memory maxSpent = new IOriginSettler.Output[](1); + IOriginSettler.Output[] memory minReceived = new IOriginSettler.Output[](1); + IOriginSettler.FillInstruction[] memory fillInstructions = new IOriginSettler.FillInstruction[](1); + maxSpent[0] = IOriginSettler.Output({ + token: bytes32(uint256(uint160(defaultOutputToken))), + amount: type(uint256).max, + recipient: bytes32(uint256(uint160(user))), + chainId: defaultOutputChainId + }); + minReceived[0] = IOriginSettler.Output({ + token: bytes32(uint256(uint160(address(usdc)))), + amount: defaultAmount, + recipient: '', + chainId: block.chainid + }); + Tribunal.BatchClaim memory claim = Tribunal.BatchClaim({ + chainId: block.chainid, + compact: _getCompact(), + sponsorSignature: sponsorSignature, + allocatorSignature: '' + }); + fillInstructions[0] = IOriginSettler.FillInstruction({ + destinationChainId: defaultOutputChainId, + destinationSettler: bytes32(uint256(uint160(tribunal))), + originData: abi.encode(claim, _getMandate(), uint256(0), uint256(0)) + }); + + IOriginSettler.ResolvedCrossChainOrder memory resolvedCrossChainOrder = IOriginSettler.ResolvedCrossChainOrder({ + user: user, + originChainId: block.chainid, + openDeadline: uint32(_getClaimExpiration()), + fillDeadline: uint32(_getFillExpiration()), + orderId: bytes32(defaultNonce), + maxSpent: maxSpent, + minReceived: minReceived, + fillInstructions: fillInstructions + }); + + vm.prank(filler); + vm.expectEmit(true, false, false, false, address(erc7683Allocator)); + emit IOriginSettler.Open(bytes32(defaultNonce), resolvedCrossChainOrder); + erc7683Allocator.openFor(gaslessCrossChainOrder_, sponsorSignature, ''); + vm.snapshotGasLastCall('openFor_simpleOrder_relayed'); + } + + function test_revert_NonceAlreadyInUse(uint256 nonce) public { + vm.assume(nonce != defaultNonce); + // Deposit tokens + vm.startPrank(user); + compactContract.depositERC20(address(usdc), usdcLockTag, defaultAmount, user); + vm.stopPrank(); + + // try to use a future nonce + IOriginSettler.GaslessCrossChainOrder memory gaslessCrossChainOrder = _getGaslessCrossChainOrder(); + gaslessCrossChainOrder.nonce = nonce; + vm.prank(user); + vm.expectRevert(abi.encodeWithSelector(IERC7683Allocator.InvalidNonce.selector, nonce, defaultNonce)); + erc7683Allocator.openFor(gaslessCrossChainOrder, '', ''); + } +} + +contract ERC7683Allocator_authorizeClaim is MockAllocator { + function test_revert_InvalidSignature() public { + // Deposit tokens + vm.startPrank(user); + compactContract.depositERC20(address(usdc), usdcLockTag, defaultAmount, user); + + // register a claim + BatchCompact memory compact_ = _getCompact(); + Mandate memory mandate_ = _getMandate(); + + (bytes32 mandateHash,) = _hashMandate(mandate_); + bytes32 claimHash = _deriveClaimHash(compact_, mandateHash); + compactContract.register(claimHash, COMPACT_TYPEHASH_WITH_MANDATE); + + address filler = makeAddr('filler'); + vm.assertEq(compactContract.balanceOf(user, usdcId), defaultAmount); + vm.assertEq(compactContract.balanceOf(filler, usdcId), 0); + + vm.stopPrank(); + + // we do NOT open the order or lock the tokens + + // claim should fail, because we did not open an order + Component memory component = Component({claimant: uint256(uint160(filler)), amount: defaultAmount}); + Component[] memory components = new Component[](1); + components[0] = component; + BatchClaimComponent memory batchClaimComponent = + BatchClaimComponent({id: usdcId, allocatedAmount: defaultAmount, portions: components}); + BatchClaimComponent[] memory batchClaimComponents = new BatchClaimComponent[](1); + batchClaimComponents[0] = batchClaimComponent; + BatchClaim memory claim = BatchClaim({ + allocatorData: '', + sponsorSignature: '', + sponsor: user, + nonce: defaultNonce, + expires: compact_.expires, + witness: bytes32(0), + witnessTypestring: '', + claims: batchClaimComponents + }); + vm.prank(arbiter); + vm.expectRevert(abi.encodeWithSelector(0x8baa579f)); // check for the InvalidSignature() error in the Compact contract + compactContract.batchClaim(claim); + + vm.assertEq(compactContract.balanceOf(user, usdcId), defaultAmount); + vm.assertEq(compactContract.balanceOf(filler, usdcId), 0); + } + + function test_revert_InvalidAllocatorData() public { + // Deposit tokens + vm.startPrank(user); + compactContract.depositERC20(address(usdc), usdcLockTag, defaultAmount, user); + + // register a claim + BatchCompact memory compact_ = _getCompact(); + Mandate memory mandate_ = _getMandate(); + + (bytes32 mandateHash2,) = _hashMandate(mandate_); + bytes32 claimHash = _deriveClaimHash(compact_, mandateHash2); + compactContract.register(claimHash, COMPACT_TYPEHASH_WITH_MANDATE); + + address filler = makeAddr('filler'); + vm.assertEq(compactContract.balanceOf(user, usdcId), defaultAmount); + vm.assertEq(usdc.balanceOf(filler), 0); + + // we open the order and lock the tokens + (IOriginSettler.OnchainCrossChainOrder memory onChainCrossChainOrder_) = _getOnChainCrossChainOrder(); + erc7683Allocator.open(onChainCrossChainOrder_); + vm.stopPrank(); + + // claim should be successful + (bytes32 witness,) = _hashMandate(mandate_); + Component[] memory components = new Component[](1); + components[0] = Component({claimant: uint256(uint160(filler)), amount: defaultAmount}); + BatchClaimComponent memory batchClaimComponent = + BatchClaimComponent({id: usdcId, allocatedAmount: defaultAmount, portions: components}); + BatchClaimComponent[] memory batchClaimComponents = new BatchClaimComponent[](1); + batchClaimComponents[0] = batchClaimComponent; + BatchClaim memory claim = BatchClaim({ + allocatorData: '', + sponsorSignature: '', + sponsor: user, + nonce: defaultNonce, + expires: compact_.expires, + witness: witness, + witnessTypestring: '', + claims: batchClaimComponents + }); + vm.prank(arbiter); + vm.expectRevert(abi.encodeWithSelector(0x8baa579f)); + compactContract.batchClaim(claim); + } + + function test_successful_open() public { + // Deposit tokens + vm.startPrank(user); + compactContract.depositERC20(address(usdc), usdcLockTag, defaultAmount, user); + + // register a claim + BatchCompact memory compact_ = _getCompact(); + Mandate memory mandate_ = _getMandate(); + + (bytes32 mandateHash,) = _hashMandate(mandate_); + bytes32 claimHash = _deriveClaimHash(compact_, mandateHash); + compactContract.register(claimHash, COMPACT_TYPEHASH_WITH_MANDATE); + + address filler = makeAddr('filler'); + vm.assertEq(compactContract.balanceOf(user, usdcId), defaultAmount); + vm.assertEq(usdc.balanceOf(filler), 0); + + // we open the order and lock the tokens + (IOriginSettler.OnchainCrossChainOrder memory onChainCrossChainOrder_) = + _getOnChainCrossChainOrder(compact_, mandate_); + erc7683Allocator.open(onChainCrossChainOrder_); + vm.stopPrank(); + + // claim should be successful + Component[] memory components = new Component[](1); + components[0] = Component({claimant: uint256(uint160(filler)), amount: compact_.commitments[0].amount}); + BatchClaimComponent memory batchClaimComponent = + BatchClaimComponent({id: usdcId, allocatedAmount: compact_.commitments[0].amount, portions: components}); + BatchClaimComponent[] memory batchClaimComponents = new BatchClaimComponent[](1); + batchClaimComponents[0] = batchClaimComponent; + BatchClaim memory claim = BatchClaim({ + allocatorData: '', + sponsorSignature: '', + sponsor: compact_.sponsor, + nonce: compact_.nonce, + expires: compact_.expires, + witness: mandateHash, + witnessTypestring: WITNESS_TYPESTRING_TRIBUNAL, + claims: batchClaimComponents + }); + vm.prank(arbiter); + compactContract.batchClaim(claim); + + vm.assertEq(compactContract.balanceOf(user, usdcId), 0); + vm.assertEq(usdc.balanceOf(filler), defaultAmount); + } + + function test_successful_openFor() public { + // Deposit tokens + vm.startPrank(user); + compactContract.depositERC20(address(usdc), usdcLockTag, defaultAmount, user); + + // register a claim + BatchCompact memory compact_ = _getCompact(); + Mandate memory mandate_ = _getMandate(); + + (bytes32 mandateHash4,) = _hashMandate(mandate_); + bytes32 claimHash = _deriveClaimHash(compact_, mandateHash4); + compactContract.register(claimHash, COMPACT_TYPEHASH_WITH_MANDATE); + + address filler = makeAddr('filler'); + vm.assertEq(compactContract.balanceOf(user, usdcId), defaultAmount); + vm.assertEq(usdc.balanceOf(filler), 0); + + // we open the order and lock the tokens + IOriginSettler.GaslessCrossChainOrder memory gaslessCrossChainOrder_ = _getGaslessCrossChainOrder(); + erc7683Allocator.openFor(gaslessCrossChainOrder_, '', ''); + vm.stopPrank(); + + // claim should be successful + Component[] memory components = new Component[](1); + components[0] = Component({claimant: uint256(uint160(filler)), amount: defaultAmount}); + BatchClaimComponent memory batchClaimComponent = + BatchClaimComponent({id: usdcId, allocatedAmount: defaultAmount, portions: components}); + BatchClaimComponent[] memory batchClaimComponents = new BatchClaimComponent[](1); + batchClaimComponents[0] = batchClaimComponent; + (bytes32 mh,) = _hashMandate(mandate_); + BatchClaim memory claim = BatchClaim({ + allocatorData: '', + sponsorSignature: '', + sponsor: compact_.sponsor, + nonce: compact_.nonce, + expires: compact_.expires, + witness: mh, + witnessTypestring: WITNESS_TYPESTRING_TRIBUNAL, + claims: batchClaimComponents + }); + vm.prank(arbiter); + compactContract.batchClaim(claim); + + vm.assertEq(compactContract.balanceOf(user, usdcId), 0); + vm.assertEq(usdc.balanceOf(filler), defaultAmount); + } +} + +contract ERC7683Allocator_isClaimAuthorized is MockAllocator { + function test_failed_noClaimAllocated() public { + // Deposit tokens + vm.startPrank(user); + compactContract.depositERC20(address(usdc), usdcLockTag, defaultAmount, user); + + // register a claim + BatchCompact memory compact_ = _getCompact(); + Mandate memory mandate_ = _getMandate(); + + (bytes32 mandateHashA,) = _hashMandate(mandate_); + bytes32 claimHash = _deriveClaimHash(compact_, mandateHashA); + compactContract.register(claimHash, COMPACT_TYPEHASH_WITH_MANDATE); + + address filler = makeAddr('filler'); + vm.assertEq(compactContract.balanceOf(user, usdcId), defaultAmount); + vm.assertEq(compactContract.balanceOf(filler, usdcId), 0); + + vm.stopPrank(); + + // we do NOT open the order or lock the tokens + + // isClaimAuthorized should be false, because we did not allocate the claim + assertFalse( + erc7683Allocator.isClaimAuthorized( + claimHash, + compact_.arbiter, + compact_.sponsor, + compact_.nonce, + compact_.expires, + defaultIdsAndAmounts, + '' + ) + ); + } + + function test_successful_open() public { + // Deposit tokens + vm.startPrank(user); + compactContract.depositERC20(address(usdc), usdcLockTag, defaultAmount, user); + + // register a claim + BatchCompact memory compact_ = _getCompact(); + Mandate memory mandate_ = _getMandate(); + + (bytes32 mandateHashC,) = _hashMandate(mandate_); + bytes32 claimHash = _deriveClaimHash(compact_, mandateHashC); + compactContract.register(claimHash, COMPACT_TYPEHASH_WITH_MANDATE); + + address filler = makeAddr('filler'); + vm.assertEq(compactContract.balanceOf(user, usdcId), defaultAmount); + vm.assertEq(usdc.balanceOf(filler), 0); + + // we open the order and lock the tokens + (IOriginSettler.OnchainCrossChainOrder memory onChainCrossChainOrder_) = _getOnChainCrossChainOrder(); + erc7683Allocator.open(onChainCrossChainOrder_); + vm.stopPrank(); + + // claim should be successful + (bytes32 witness,) = _hashMandate(mandate_); + Component[] memory components = new Component[](1); + components[0] = Component({claimant: uint256(uint160(filler)), amount: defaultAmount}); + BatchClaimComponent memory batchClaimComponent = + BatchClaimComponent({id: usdcId, allocatedAmount: defaultAmount, portions: components}); + BatchClaimComponent[] memory batchClaimComponents = new BatchClaimComponent[](1); + batchClaimComponents[0] = batchClaimComponent; + BatchClaim memory claim = BatchClaim({ + allocatorData: '', + sponsorSignature: '', + sponsor: user, + nonce: defaultNonce, + expires: compact_.expires, + witness: witness, + witnessTypestring: WITNESS_TYPESTRING_TRIBUNAL, + claims: batchClaimComponents + }); + vm.prank(arbiter); + compactContract.batchClaim(claim); + + vm.assertEq(compactContract.balanceOf(user, usdcId), 0); + vm.assertEq(usdc.balanceOf(filler), defaultAmount); + } + + function test_successful_openFor() public { + // Deposit tokens + vm.startPrank(user); + compactContract.depositERC20(address(usdc), usdcLockTag, defaultAmount, user); + + // register a claim + BatchCompact memory compact_ = _getCompact(); + Mandate memory mandate_ = _getMandate(); + + (bytes32 mandateHashD,) = _hashMandate(mandate_); + bytes32 claimHash = _deriveClaimHash(compact_, mandateHashD); + compactContract.register(claimHash, COMPACT_TYPEHASH_WITH_MANDATE); + + address filler = makeAddr('filler'); + vm.assertEq(compactContract.balanceOf(user, usdcId), defaultAmount); + vm.assertEq(usdc.balanceOf(filler), 0); + + // we open the order and lock the tokens + IOriginSettler.GaslessCrossChainOrder memory gaslessCrossChainOrder_ = _getGaslessCrossChainOrder(); + erc7683Allocator.openFor(gaslessCrossChainOrder_, '', ''); + vm.stopPrank(); + + // claim should be successful + Component[] memory components = new Component[](1); + components[0] = Component({claimant: uint256(uint160(filler)), amount: defaultAmount}); + BatchClaimComponent memory batchClaimComponent = + BatchClaimComponent({id: usdcId, allocatedAmount: defaultAmount, portions: components}); + BatchClaimComponent[] memory batchClaimComponents = new BatchClaimComponent[](1); + batchClaimComponents[0] = batchClaimComponent; + (bytes32 mh,) = _hashMandate(mandate_); + BatchClaim memory claim = BatchClaim({ + allocatorData: '', + sponsorSignature: '', + sponsor: compact_.sponsor, + nonce: compact_.nonce, + expires: compact_.expires, + witness: mh, + witnessTypestring: WITNESS_TYPESTRING_TRIBUNAL, + claims: batchClaimComponents + }); + vm.prank(arbiter); + compactContract.batchClaim(claim); + + vm.assertEq(compactContract.balanceOf(user, usdcId), 0); + vm.assertEq(usdc.balanceOf(filler), defaultAmount); + } +} + +contract ERC7683Allocator_resolveFor is MockAllocator { + function test_revert_InvalidOrderDataType() public { + IOriginSettler.GaslessCrossChainOrder memory gaslessCrossChainOrder_ = _getGaslessCrossChainOrder(); + gaslessCrossChainOrder_.orderDataType = keccak256('false'); + vm.expectRevert( + abi.encodeWithSelector( + IERC7683Allocator.InvalidOrderDataType.selector, + gaslessCrossChainOrder_.orderDataType, + ORDERDATA_GASLESS_TYPEHASH + ) + ); + erc7683Allocator.resolveFor(gaslessCrossChainOrder_, ''); + } + + function test_revert_InvalidOriginSettler() public { + IOriginSettler.GaslessCrossChainOrder memory gaslessCrossChainOrder_ = _getGaslessCrossChainOrder(); + gaslessCrossChainOrder_.originSettler = makeAddr('invalid'); + vm.expectRevert( + abi.encodeWithSelector( + IERC7683Allocator.InvalidOriginSettler.selector, + gaslessCrossChainOrder_.originSettler, + address(erc7683Allocator) + ) + ); + erc7683Allocator.resolveFor(gaslessCrossChainOrder_, ''); + } + + function test_revert_InvalidNonce() public { + IOriginSettler.GaslessCrossChainOrder memory gaslessCrossChainOrder_ = _getGaslessCrossChainOrder(); + gaslessCrossChainOrder_.nonce = defaultNonce + 1; + vm.expectRevert( + abi.encodeWithSelector(IERC7683Allocator.InvalidNonce.selector, gaslessCrossChainOrder_.nonce, defaultNonce) + ); + erc7683Allocator.resolveFor(gaslessCrossChainOrder_, ''); + } + + function test_resolve_successful() public { + // WITH THE CURRENT ERC7683 DESIGN, THE SPONSOR SIGNATURE IS NOT PROVIDED TO THE RESOLVE FUNCTION + // WHILE THE ResolvedCrossChainOrder WITHOUT THE SIGNATURE COULD STILL BE USED TO SIMULATE THE FILL, + // ACTUALLY USING THIS DATA WOULD RESULT IN A LOSS OF THE REWARD TOKENS FOR THE FILLER. + // THIS FEELS RISKY. + // THE CURRENT ALTERNATIVE WOULD BE HAVE THE INPUT SIGNATURE BEING LEFT EMPTY AND INSTEAD BE PROVIDED IN THE THE orderData OF THE GaslessCrossChainOrderData. + // THIS IS BOTH NOT IDEAL, SO CURRENTLY CHECKING FOR A SOLUTION. + + IOriginSettler.GaslessCrossChainOrder memory gaslessCrossChainOrder_ = _getGaslessCrossChainOrder(); + IOriginSettler.Output[] memory maxSpent = new IOriginSettler.Output[](1); + IOriginSettler.Output[] memory minReceived = new IOriginSettler.Output[](1); + IOriginSettler.FillInstruction[] memory fillInstructions = new IOriginSettler.FillInstruction[](1); + maxSpent[0] = IOriginSettler.Output({ + token: bytes32(uint256(uint160(defaultOutputToken))), + amount: type(uint256).max, + recipient: bytes32(uint256(uint160(user))), + chainId: defaultOutputChainId + }); + minReceived[0] = IOriginSettler.Output({ + token: bytes32(uint256(uint160(address(usdc)))), + amount: defaultAmount, + recipient: '', + chainId: block.chainid + }); + BatchCompact memory compactExpected = _getCompact(); + compactExpected.nonce = defaultNonce; + Tribunal.BatchClaim memory claim = Tribunal.BatchClaim({ + chainId: block.chainid, + compact: compactExpected, + sponsorSignature: '', // sponsorSignature, // THE SIGNATURE MUST BE ADDED MANUALLY BY THE FILLER WITH THE CURRENT SYSTEM, BEFORE FILLING THE ORDER ON THE TARGET CHAIN + allocatorSignature: '' + }); + fillInstructions[0] = IOriginSettler.FillInstruction({ + destinationChainId: defaultOutputChainId, + destinationSettler: bytes32(uint256(uint160(tribunal))), + originData: abi.encode(claim, _getMandate().fills[0], adjuster, _buildFillHashes(_getMandate())) + }); + + IOriginSettler.ResolvedCrossChainOrder memory resolvedCrossChainOrder = IOriginSettler.ResolvedCrossChainOrder({ + user: user, + originChainId: block.chainid, + openDeadline: uint32(_getClaimExpiration()), + fillDeadline: uint32(_getFillExpiration()), + orderId: bytes32(defaultNonce), + maxSpent: maxSpent, + minReceived: minReceived, + fillInstructions: fillInstructions + }); + IOriginSettler.ResolvedCrossChainOrder memory resolved = + erc7683Allocator.resolveFor(gaslessCrossChainOrder_, ''); + assertEq(resolved.user, resolvedCrossChainOrder.user); + assertEq(resolved.originChainId, resolvedCrossChainOrder.originChainId); + assertEq(resolved.openDeadline, resolvedCrossChainOrder.openDeadline); + assertEq(resolved.fillDeadline, resolvedCrossChainOrder.fillDeadline); + assertEq(resolved.orderId, resolvedCrossChainOrder.orderId); + assertEq(resolved.maxSpent.length, resolvedCrossChainOrder.maxSpent.length); + assertEq(resolved.maxSpent[0].token, resolvedCrossChainOrder.maxSpent[0].token); + assertEq(resolved.maxSpent[0].amount, resolvedCrossChainOrder.maxSpent[0].amount); + assertEq(resolved.maxSpent[0].recipient, resolvedCrossChainOrder.maxSpent[0].recipient); + assertEq(resolved.maxSpent[0].chainId, resolvedCrossChainOrder.maxSpent[0].chainId); + assertEq(resolved.minReceived.length, resolvedCrossChainOrder.minReceived.length); + assertEq(resolved.minReceived[0].token, resolvedCrossChainOrder.minReceived[0].token); + assertEq(resolved.minReceived[0].amount, resolvedCrossChainOrder.minReceived[0].amount); + assertEq(resolved.minReceived[0].recipient, resolvedCrossChainOrder.minReceived[0].recipient); + assertEq(resolved.minReceived[0].chainId, resolvedCrossChainOrder.minReceived[0].chainId); + assertEq(resolved.fillInstructions.length, resolvedCrossChainOrder.fillInstructions.length); + assertEq( + resolved.fillInstructions[0].destinationChainId, + resolvedCrossChainOrder.fillInstructions[0].destinationChainId + ); + assertEq( + resolved.fillInstructions[0].destinationSettler, + resolvedCrossChainOrder.fillInstructions[0].destinationSettler + ); + assertEq(resolved.fillInstructions[0].originData, resolvedCrossChainOrder.fillInstructions[0].originData); + } +} + +contract ERC7683Allocator_resolve is MockAllocator { + function test_revert_InvalidOrderDataType() public { + (IOriginSettler.OnchainCrossChainOrder memory onChainCrossChainOrder_) = _getOnChainCrossChainOrder(); + onChainCrossChainOrder_.orderDataType = keccak256('false'); + vm.expectRevert( + abi.encodeWithSelector( + IERC7683Allocator.InvalidOrderDataType.selector, + onChainCrossChainOrder_.orderDataType, + ORDERDATA_ONCHAIN_TYPEHASH + ) + ); + erc7683Allocator.resolve(onChainCrossChainOrder_); + } + + function test_resolve_successful() public { + (IOriginSettler.OnchainCrossChainOrder memory onChainCrossChainOrder_) = _getOnChainCrossChainOrder(); + IOriginSettler.Output[] memory maxSpent = new IOriginSettler.Output[](1); + IOriginSettler.Output[] memory minReceived = new IOriginSettler.Output[](1); + IOriginSettler.FillInstruction[] memory fillInstructions = new IOriginSettler.FillInstruction[](1); + maxSpent[0] = IOriginSettler.Output({ + token: bytes32(uint256(uint160(defaultOutputToken))), + amount: type(uint256).max, + recipient: bytes32(uint256(uint160(user))), + chainId: defaultOutputChainId + }); + minReceived[0] = IOriginSettler.Output({ + token: bytes32(uint256(uint160(address(usdc)))), + amount: defaultAmount, + recipient: '', + chainId: block.chainid + }); + BatchCompact memory compactExpected = _getCompact(); + compactExpected.nonce = defaultNonce; + Tribunal.BatchClaim memory claim = Tribunal.BatchClaim({ + chainId: block.chainid, + compact: compactExpected, + sponsorSignature: '', + allocatorSignature: '' + }); + fillInstructions[0] = IOriginSettler.FillInstruction({ + destinationChainId: defaultOutputChainId, + destinationSettler: bytes32(uint256(uint160(tribunal))), + originData: abi.encode(claim, _getMandate().fills[0], adjuster, _buildFillHashes(_getMandate())) + }); + + IOriginSettler.ResolvedCrossChainOrder memory resolvedCrossChainOrder = IOriginSettler.ResolvedCrossChainOrder({ + user: user, + originChainId: block.chainid, + openDeadline: uint32(_getClaimExpiration()), + fillDeadline: uint32(_getFillExpiration()), + orderId: bytes32(defaultNonce), + maxSpent: maxSpent, + minReceived: minReceived, + fillInstructions: fillInstructions + }); + vm.prank(user); + IOriginSettler.ResolvedCrossChainOrder memory resolved = erc7683Allocator.resolve(onChainCrossChainOrder_); + assertEq(resolved.user, resolvedCrossChainOrder.user); + assertEq(resolved.originChainId, resolvedCrossChainOrder.originChainId); + assertEq(resolved.openDeadline, resolvedCrossChainOrder.openDeadline); + assertEq(resolved.fillDeadline, resolvedCrossChainOrder.fillDeadline); + assertEq(resolved.orderId, resolvedCrossChainOrder.orderId); + assertEq(resolved.maxSpent.length, resolvedCrossChainOrder.maxSpent.length); + assertEq(resolved.maxSpent[0].token, resolvedCrossChainOrder.maxSpent[0].token); + assertEq(resolved.maxSpent[0].amount, resolvedCrossChainOrder.maxSpent[0].amount); + assertEq(resolved.maxSpent[0].recipient, resolvedCrossChainOrder.maxSpent[0].recipient); + assertEq(resolved.maxSpent[0].chainId, resolvedCrossChainOrder.maxSpent[0].chainId); + assertEq(resolved.minReceived.length, resolvedCrossChainOrder.minReceived.length); + assertEq(resolved.minReceived[0].token, resolvedCrossChainOrder.minReceived[0].token); + assertEq(resolved.minReceived[0].amount, resolvedCrossChainOrder.minReceived[0].amount); + assertEq(resolved.minReceived[0].recipient, resolvedCrossChainOrder.minReceived[0].recipient); + assertEq(resolved.minReceived[0].chainId, resolvedCrossChainOrder.minReceived[0].chainId); + assertEq(resolved.fillInstructions.length, resolvedCrossChainOrder.fillInstructions.length); + assertEq( + resolved.fillInstructions[0].destinationChainId, + resolvedCrossChainOrder.fillInstructions[0].destinationChainId + ); + assertEq( + resolved.fillInstructions[0].destinationSettler, + resolvedCrossChainOrder.fillInstructions[0].destinationSettler + ); + assertEq(resolved.fillInstructions[0].originData, resolvedCrossChainOrder.fillInstructions[0].originData); + } +} + +contract ERC7683Allocator_getCompactWitnessTypeString is MockAllocator { + function test_getCompactWitnessTypeString() public view { + bytes memory s = bytes(erc7683Allocator.getCompactWitnessTypeString()); + assertTrue(s.length > 0); + } +} + +// Removed: nonce check suite not applicable to the new interface + +contract ERC7683Allocator_createFillerData is MockAllocator { + function test_createFillerData(address claimant) public view { + bytes memory fillerData = erc7683Allocator.createFillerData(claimant); + assertEq(abi.decode(fillerData, (address)), claimant); + } +} + +// ------------------------------------------------------------ +// Tests for _openAndRegister path via openFor with deposit true +// ------------------------------------------------------------ +contract ERC7683Allocator_openForDeposit is MockAllocator { + function test_openFor_withDeposit_success_emptyInputs(address relayer) public { + vm.assume(relayer != address(0)); + assertEq(ERC6909(address(compactContract)).balanceOf(user, usdcId), 0); + + usdc.mint(address(erc7683Allocator), defaultAmount); + + BatchCompact memory compact_ = _getCompact(); + compact_.commitments[0].amount = 0; + + Mandate memory mandate_ = _getMandate(); + IOriginSettler.GaslessCrossChainOrder memory order_ = _getGaslessCrossChainOrder(compact_, mandate_, true); + + vm.prank(relayer); + erc7683Allocator.openFor(order_, '', ''); + + assertEq(ERC6909(address(compactContract)).balanceOf(user, usdcId), defaultAmount); + + compact_.nonce = _composeNonceUint(relayer, 1); + compact_.commitments[0].amount = defaultAmount; + + (bytes32 mandateHash,) = _hashMandate(mandate_); + bytes32 claimHash = _deriveClaimHash(compact_, mandateHash); + assertTrue(compactContract.isRegistered(user, claimHash, COMPACT_TYPEHASH_WITH_MANDATE)); + } + + function test_openFor_withDeposit_success(address relayer) public { + vm.assume(relayer != address(0)); + + assertEq(ERC6909(address(compactContract)).balanceOf(user, usdcId), 0); + + uint256 amount = defaultAmount; + usdc.mint(address(erc7683Allocator), amount); + + BatchCompact memory compact_ = _getCompact(); + compact_.nonce = _composeNonceUint(relayer, 1); + + Mandate memory mandate_ = _getMandate(); + IOriginSettler.GaslessCrossChainOrder memory order_ = _getGaslessCrossChainOrder(compact_, mandate_, true); + + vm.prank(relayer); + erc7683Allocator.openFor(order_, '', ''); + + // Check Balance + assertEq(ERC6909(address(compactContract)).balanceOf(user, usdcId), amount); + + (bytes32 mandateHash,) = _hashMandate(mandate_); + bytes32 claimHash = _deriveClaimHash(compact_, mandateHash); + assertTrue(compactContract.isRegistered(user, claimHash, COMPACT_TYPEHASH_WITH_MANDATE)); + } +} diff --git a/test/HybridAllocator.t.sol b/test/HybridAllocator.t.sol new file mode 100644 index 0000000..a6aa6f5 --- /dev/null +++ b/test/HybridAllocator.t.sol @@ -0,0 +1,1079 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import {TestHelper} from './util/TestHelper.sol'; +import {ERC20} from '@solady/tokens/ERC20.sol'; +import {TheCompact} from '@uniswap/the-compact/TheCompact.sol'; +import {ITheCompact} from '@uniswap/the-compact/interfaces/ITheCompact.sol'; + +import {BatchClaim} from '@uniswap/the-compact/types/BatchClaims.sol'; +import {BatchClaimComponent, Component} from '@uniswap/the-compact/types/Components.sol'; +import {BATCH_COMPACT_TYPEHASH, BatchCompact, Lock} from '@uniswap/the-compact/types/EIP712Types.sol'; +import {ResetPeriod} from '@uniswap/the-compact/types/ResetPeriod.sol'; +import {Scope} from '@uniswap/the-compact/types/Scope.sol'; + +import {Test} from 'forge-std/Test.sol'; +import {HybridAllocator} from 'src/allocators/HybridAllocator.sol'; + +import {AllocatorLib} from 'src/allocators/lib/AllocatorLib.sol'; +import {BATCH_COMPACT_WITNESS_TYPEHASH} from 'src/allocators/lib/TypeHashes.sol'; +import {IHybridAllocator} from 'src/interfaces/IHybridAllocator.sol'; +import {ERC20Mock} from 'src/test/ERC20Mock.sol'; +import {OnChainAllocationCaller} from 'src/test/OnChainAllocationCaller.sol'; + +contract HybridAllocatorFactory { + function deploy(bytes32 salt, address compact, address signer) external returns (address) { + return address(new HybridAllocator{salt: salt}(compact, signer)); + } +} + +contract HybridAllocatorTest is Test, TestHelper { + TheCompact compact; + address arbiter; + HybridAllocator allocator; + address signer; + uint256 signerPrivateKey; + ERC20Mock usdc; + address user; + uint256 userPrivateKey; + uint256 defaultAmount; + uint256 defaultExpiration; + + OnChainAllocationCaller allocationCaller; + + BatchCompact batchCompact; + + function setUp() public { + compact = new TheCompact(); + arbiter = makeAddr('arbiter'); + (signer, signerPrivateKey) = makeAddrAndKey('signer'); + allocator = new HybridAllocator(address(compact), signer); + usdc = new ERC20Mock('USDC', 'USDC'); + (user, userPrivateKey) = makeAddrAndKey('user'); + deal(user, 1 ether); + usdc.mint(user, 1 ether); + defaultAmount = 1 ether; + defaultExpiration = vm.getBlockTimestamp() + 1 days; + + allocationCaller = new OnChainAllocationCaller(address(allocator), address(compact)); + + batchCompact.arbiter = arbiter; + batchCompact.sponsor = user; + batchCompact.nonce = 1; + batchCompact.expires = defaultExpiration; + } + + function _idsAndAmounts(address token, uint256 amount) internal view returns (uint256[2][] memory arr) { + arr = new uint256[2][](1); + arr[0][0] = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), token); + arr[0][1] = amount; + } + + function _idsAndAmounts2(address tokenA, uint256 amountA, address tokenB, uint256 amountB) + internal + view + returns (uint256[2][] memory arr) + { + arr = new uint256[2][](2); + arr[0][0] = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), tokenA); + arr[0][1] = amountA; + arr[1][0] = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), tokenB); + arr[1][1] = amountB; + } + + function test_constructor_revert_signerIsAddressZero() public { + vm.expectRevert(abi.encodeWithSelector(IHybridAllocator.InvalidSigner.selector)); + new HybridAllocator(address(compact), address(0)); + } + + function test_checkAllocatorId() public view { + assertEq(allocator.ALLOCATOR_ID(), _toAllocatorId(address(allocator))); + } + + function test_checkNonce() public view { + assertEq(allocator.nonces(), 0); + } + + function test_checkSignerCount() public view { + assertEq(allocator.signerCount(), 1); + } + + function test_checkSigners(address attacker) public view { + vm.assume(attacker != signer); + + assertTrue(allocator.signers(signer)); + assertFalse(allocator.signers(attacker)); + } + + function test_prepareAllocation_returnsNonce_andDoesNotIncrement() public { + uint256[2][] memory idsAndAmounts = _idsAndAmounts(address(usdc), defaultAmount); + uint96 beforeNonces = allocator.nonces(); + // call prepare directly + uint256 returnedNonce = allocator.prepareAllocation( + user, idsAndAmounts, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, bytes32(0), '' + ); + assertEq(returnedNonce, uint256(beforeNonces) + 1); + // storage not incremented yet + assertEq(allocator.nonces(), beforeNonces); + } + + function test_executeAllocation_success_viaCaller_singleERC20() public { + uint256 amount = defaultAmount; + uint256[2][] memory idsAndAmounts = _idsAndAmounts(address(usdc), amount); + // fund caller and approve + usdc.mint(address(allocationCaller), amount); + vm.prank(address(allocationCaller)); + usdc.approve(address(compact), amount); + + // run flow in one tx + vm.prank(user); + allocationCaller.onChainAllocation( + user, idsAndAmounts, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, '', 0 + ); + vm.snapshotGasLastCall('hybrid_execute_single'); + + // nonces incremented + assertEq(allocator.nonces(), 1); + + // derive claim hash and ensure isClaimAuthorized is true + Lock[] memory commitments = _idsAndAmountsToCommitments(idsAndAmounts); + bytes32 claimHash = _toBatchCompactHash( + BatchCompact({ + arbiter: arbiter, + sponsor: user, + nonce: allocator.nonces(), + expires: defaultExpiration, + commitments: commitments + }) + ); + assertTrue(allocator.isClaimAuthorized(claimHash, address(0), address(0), 0, 0, new uint256[2][](0), '')); + } + + function test_executeAllocation_revert_InvalidPreparation() public { + uint256 amount = defaultAmount; + uint256[2][] memory idsAndAmounts = _idsAndAmounts(address(usdc), amount); + // fund caller and approve + usdc.mint(address(allocationCaller), amount); + vm.prank(address(allocationCaller)); + usdc.approve(address(compact), amount); + + // todo=2: deposit+register without prepare -> expect AllocatorLib.InvalidPreparation + vm.prank(user); + vm.expectRevert(AllocatorLib.InvalidPreparation.selector); + allocationCaller.onChainAllocation( + user, idsAndAmounts, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, '', 2 + ); + } + + function test_executeAllocation_revert_InvalidRegistration() public { + uint256 amount = defaultAmount; + uint256[2][] memory idsAndAmounts = _idsAndAmounts(address(usdc), amount); + // fund caller and approve + usdc.mint(address(allocationCaller), amount); + vm.prank(address(allocationCaller)); + usdc.approve(address(compact), amount); + + // Compute expected claim hash for the deposit-only path + Lock[] memory commitments = _idsAndAmountsToCommitments(idsAndAmounts); + uint256 expectedNonce = uint256(allocator.nonces()) + 1; // prepare will use this + bytes32 expectedClaimHash = _toBatchCompactHash( + BatchCompact({ + arbiter: arbiter, + sponsor: user, + nonce: expectedNonce, + expires: defaultExpiration, + commitments: commitments + }) + ); + + // todo=1: deposit only, no register -> AllocatorLib.InvalidRegistration + vm.prank(user); + vm.expectRevert( + abi.encodeWithSelector( + AllocatorLib.InvalidRegistration.selector, user, expectedClaimHash, BATCH_COMPACT_TYPEHASH + ) + ); + allocationCaller.onChainAllocation( + user, idsAndAmounts, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, '', 1 + ); + } + + function test_executeAllocation_revert_InvalidBalanceChange_noDeposit() public { + uint256 amount = defaultAmount; + uint256[2][] memory idsAndAmounts = _idsAndAmounts(address(usdc), amount); + // give user prior ERC6909 balance so (oldBalance > 0) + bytes12 lockTag = _toLockTag(address(allocator), Scope.Multichain, ResetPeriod.TenMinutes); + vm.startPrank(user); + usdc.mint(user, amount); + usdc.approve(address(compact), amount); + compact.depositERC20(address(usdc), lockTag, amount, user); + vm.stopPrank(); + + vm.prank(user); + vm.expectRevert(abi.encodeWithSignature('InvalidBalanceChange(uint256,uint256)', amount, amount)); + allocationCaller.onChainAllocation( + user, idsAndAmounts, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, '', 3 + ); + } + + function test_executeAllocation_revert_InvalidPreparation_replaySameTx() public { + uint256 amount = defaultAmount; + uint256[2][] memory idsAndAmounts = _idsAndAmounts(address(usdc), amount); + // fund caller and approve + usdc.mint(address(allocationCaller), amount); + vm.prank(address(allocationCaller)); + usdc.approve(address(compact), amount); + + vm.prank(user); + vm.expectRevert(AllocatorLib.InvalidPreparation.selector); + allocationCaller.onChainAllocation( + user, idsAndAmounts, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, '', 4 + ); + } + + function test_allocateAndRegister_revert_InvalidIds() public { + vm.expectRevert(IHybridAllocator.InvalidIds.selector); + allocator.allocateAndRegister(user, new uint256[2][](0), arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, ''); + } + + function test_allocateAndRegister_revert_InvalidAllocatorIdNative() public { + uint256[2][] memory idsAndAmounts = new uint256[2][](1); + idsAndAmounts[0][0] = + _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(this), /* wrong address */ address(0)); + idsAndAmounts[0][1] = defaultAmount; + vm.expectRevert( + abi.encodeWithSelector( + IHybridAllocator.InvalidAllocatorId.selector, _toAllocatorId(address(this)), allocator.ALLOCATOR_ID() + ) + ); + allocator.allocateAndRegister{value: defaultAmount}( + user, idsAndAmounts, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, '' + ); + } + + function test_allocateAndRegister_revert_InvalidAllocatorIdERC20() public { + uint256[2][] memory idsAndAmounts = new uint256[2][](1); + idsAndAmounts[0][0] = + _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(this), /* wrong address */ address(usdc)); + idsAndAmounts[0][1] = defaultAmount; + vm.expectRevert( + abi.encodeWithSelector( + IHybridAllocator.InvalidAllocatorId.selector, _toAllocatorId(address(this)), allocator.ALLOCATOR_ID() + ) + ); + allocator.allocateAndRegister(user, idsAndAmounts, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, ''); + } + + function test_allocateAndRegister_revert_InvalidValue() public { + uint256[2][] memory idsAndAmounts = new uint256[2][](1); + idsAndAmounts[0][0] = + _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(0) /* use native */ ); + idsAndAmounts[0][1] = defaultAmount; + vm.expectRevert( + abi.encodeWithSelector(IHybridAllocator.InvalidValue.selector, defaultAmount + 1, defaultAmount) + ); + allocator.allocateAndRegister{value: defaultAmount + 1}( + user, idsAndAmounts, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, '' + ); + } + + function test_allocateAndRegister_revert_zeroNativeTokensAmount() public { + uint256[2][] memory idsAndAmounts = new uint256[2][](1); + idsAndAmounts[0][0] = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(0)); + idsAndAmounts[0][1] = 0; + vm.expectRevert(abi.encodeWithSelector(ITheCompact.InvalidBatchDepositStructure.selector)); + allocator.allocateAndRegister(user, idsAndAmounts, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, ''); + } + + function test_allocateAndRegister_revert_zeroTokensAmount() public { + uint256[2][] memory idsAndAmounts = new uint256[2][](1); + idsAndAmounts[0][0] = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(usdc)); + idsAndAmounts[0][1] = 0; + vm.expectRevert(abi.encodeWithSelector(ITheCompact.InvalidDepositBalanceChange.selector)); + allocator.allocateAndRegister(user, idsAndAmounts, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, ''); + } + + function test_allocateAndRegister_revert_tokensNotProvided() public { + uint256[2][] memory idsAndAmounts = new uint256[2][](1); + idsAndAmounts[0][0] = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(usdc)); + idsAndAmounts[0][1] = defaultAmount; + vm.expectRevert(abi.encodeWithSignature('TransferFromFailed()')); + allocator.allocateAndRegister(user, idsAndAmounts, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, ''); + } + + function test_allocateAndRegister_revert_invalidTokenOrder() public { + uint256[2][] memory idsAndAmounts = new uint256[2][](2); + idsAndAmounts[0][0] = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(usdc)); + idsAndAmounts[0][1] = 0; + + idsAndAmounts[1][0] = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(0)); + idsAndAmounts[1][1] = 0; + + // Provide tokens + vm.prank(user); + usdc.transfer(address(allocator), defaultAmount); + assertEq(usdc.balanceOf(address(allocator)), defaultAmount); + + vm.expectRevert(); // Will revert when trying to approve tokens of address(0) + allocator.allocateAndRegister{value: defaultAmount}( + user, idsAndAmounts, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, '' + ); + } + + function test_allocateAndRegister_success_nativeToken() public { + uint256[2][] memory idsAndAmounts = new uint256[2][](1); + idsAndAmounts[0][0] = + _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(0) /* use native */ ); + idsAndAmounts[0][1] = defaultAmount; + (bytes32 claimHash, uint256[] memory registeredAmounts, uint256 nonce) = allocator.allocateAndRegister{ + value: defaultAmount + }(user, idsAndAmounts, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, ''); + vm.snapshotGasLastCall('allocateAndRegister_nativeToken'); + + assertTrue(compact.isRegistered(user, claimHash, BATCH_COMPACT_TYPEHASH)); + assertTrue(allocator.isClaimAuthorized(claimHash, address(0), address(0), 0, 0, new uint256[2][](0), '')); + assertEq(registeredAmounts[0], defaultAmount); + assertEq(registeredAmounts.length, 1); + assertEq(address(compact).balance, defaultAmount); + assertEq(compact.balanceOf(address(user), idsAndAmounts[0][0]), defaultAmount); + assertEq(nonce, 1); + } + + function test_allocateAndRegister_success_erc20Token() public { + uint256[2][] memory idsAndAmounts = new uint256[2][](1); + idsAndAmounts[0][0] = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(usdc)); + idsAndAmounts[0][1] = defaultAmount; + + // Provide tokens + vm.prank(user); + usdc.transfer(address(allocator), defaultAmount); + assertEq(usdc.balanceOf(address(allocator)), defaultAmount); + + (bytes32 claimHash, uint256[] memory registeredAmounts, uint256 nonce) = + allocator.allocateAndRegister(user, idsAndAmounts, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, ''); + vm.snapshotGasLastCall('allocateAndRegister_erc20Token'); + + assertTrue(compact.isRegistered(user, claimHash, BATCH_COMPACT_TYPEHASH)); + assertTrue(allocator.isClaimAuthorized(claimHash, address(0), address(0), 0, 0, new uint256[2][](0), '')); + assertEq(registeredAmounts[0], defaultAmount); + assertEq(usdc.balanceOf(address(compact)), defaultAmount); + assertEq(compact.balanceOf(address(user), idsAndAmounts[0][0]), defaultAmount); + assertEq(nonce, 1); + } + + function test_allocateAndRegister_success_nativeTokenWithEmptyAmountInput() public { + uint256[2][] memory idsAndAmounts = new uint256[2][](1); + idsAndAmounts[0][0] = + _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(0) /* use native */ ); + idsAndAmounts[0][1] = 0; + (bytes32 claimHash, uint256[] memory registeredAmounts, uint256 nonce) = allocator.allocateAndRegister{ + value: defaultAmount + }(user, idsAndAmounts, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, ''); + vm.snapshotGasLastCall('allocateAndRegister_nativeToken_emptyAmountInput'); + + assertTrue(compact.isRegistered(user, claimHash, BATCH_COMPACT_TYPEHASH)); + assertTrue(allocator.isClaimAuthorized(claimHash, address(0), address(0), 0, 0, new uint256[2][](0), '')); + assertEq(registeredAmounts[0], defaultAmount); + assertEq(address(compact).balance, defaultAmount); + assertEq(compact.balanceOf(address(user), idsAndAmounts[0][0]), defaultAmount); + assertEq(nonce, 1); + } + + function test_allocateAndRegister_success_erc20TokenWithEmptyAmountInput() public { + uint256[2][] memory idsAndAmounts = new uint256[2][](1); + idsAndAmounts[0][0] = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(usdc)); + idsAndAmounts[0][1] = 0; + + // Provide tokens + vm.prank(user); + usdc.transfer(address(allocator), defaultAmount); + assertEq(usdc.balanceOf(address(allocator)), defaultAmount); + + (bytes32 claimHash, uint256[] memory registeredAmounts, uint256 nonce) = + allocator.allocateAndRegister(user, idsAndAmounts, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, ''); + vm.snapshotGasLastCall('allocateAndRegister_erc20Token_emptyAmountInput'); + + assertTrue(compact.isRegistered(user, claimHash, BATCH_COMPACT_TYPEHASH)); + assertTrue(allocator.isClaimAuthorized(claimHash, address(0), address(0), 0, 0, new uint256[2][](0), '')); + assertEq(registeredAmounts[0], defaultAmount); + assertEq(registeredAmounts.length, 1); + assertEq(usdc.balanceOf(address(compact)), defaultAmount); + assertEq(compact.balanceOf(address(user), idsAndAmounts[0][0]), defaultAmount); + assertEq(nonce, 1); + } + + function test_allocateAndRegister_success_multipleTokens() public { + uint256[2][] memory idsAndAmounts = new uint256[2][](2); + idsAndAmounts[0][0] = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(0)); + idsAndAmounts[0][1] = 0; + + idsAndAmounts[1][0] = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(usdc)); + idsAndAmounts[1][1] = 0; + + // Provide tokens + vm.prank(user); + usdc.transfer(address(allocator), defaultAmount); + assertEq(usdc.balanceOf(address(allocator)), defaultAmount); + + (bytes32 claimHash, uint256[] memory registeredAmounts, uint256 nonce) = allocator.allocateAndRegister{ + value: defaultAmount + }(user, idsAndAmounts, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, ''); + vm.snapshotGasLastCall('allocateAndRegister_multipleTokens'); + + assertTrue(compact.isRegistered(user, claimHash, BATCH_COMPACT_TYPEHASH)); + assertTrue(allocator.isClaimAuthorized(claimHash, address(0), address(0), 0, 0, new uint256[2][](0), '')); + assertEq(registeredAmounts[0], defaultAmount); + assertEq(registeredAmounts[1], defaultAmount); + assertEq(registeredAmounts.length, 2); + assertEq(usdc.balanceOf(address(compact)), defaultAmount); + assertEq(address(compact).balance, defaultAmount); + assertEq(compact.balanceOf(address(user), idsAndAmounts[0][0]), defaultAmount); + assertEq(compact.balanceOf(address(user), idsAndAmounts[1][0]), defaultAmount); + assertEq(nonce, 1); + } + + function test_allocateAndRegister_checkNonceIncrements_nativeToken() public { + uint256[2][] memory idsAndAmounts = new uint256[2][](1); + idsAndAmounts[0][0] = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(0)); + idsAndAmounts[0][1] = 0; + + assertEq(allocator.nonces(), 0); + + // Register first claim + allocator.allocateAndRegister{value: 5e17}( + user, idsAndAmounts, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, '' + ); + assertEq(allocator.nonces(), 1); + + // Register second claim + (bytes32 claimHash, uint256[] memory registeredAmounts,) = allocator.allocateAndRegister{value: 5e17}( + user, idsAndAmounts, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, '' + ); + vm.snapshotGasLastCall('allocateAndRegister_second_nativeToken'); + + assertTrue(compact.isRegistered(user, claimHash, BATCH_COMPACT_TYPEHASH)); + assertTrue(allocator.isClaimAuthorized(claimHash, address(0), address(0), 0, 0, new uint256[2][](0), '')); + assertEq(registeredAmounts[0], 5e17); + assertEq(registeredAmounts.length, 1); + + assertEq(allocator.nonces(), 2); + } + + function test_allocateAndRegister_checkNonceIncrements_erc20Token() public { + uint256[2][] memory idsAndAmounts = new uint256[2][](1); + idsAndAmounts[0][0] = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(usdc)); + idsAndAmounts[0][1] = 0; + + assertEq(allocator.nonces(), 0); + + // Provide tokens + vm.prank(user); + usdc.transfer(address(allocator), defaultAmount / 2); + assertEq(usdc.balanceOf(address(allocator)), defaultAmount / 2); + + // Register first claim + allocator.allocateAndRegister(user, idsAndAmounts, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, ''); + assertEq(allocator.nonces(), 1); + + // Provide tokens + vm.prank(user); + usdc.transfer(address(allocator), defaultAmount / 2); + assertEq(usdc.balanceOf(address(allocator)), defaultAmount / 2); + + // Register second claim + (bytes32 claimHash, uint256[] memory registeredAmounts,) = + allocator.allocateAndRegister(user, idsAndAmounts, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, ''); + vm.snapshotGasLastCall('allocateAndRegister_second_erc20Token'); + + assertTrue(compact.isRegistered(user, claimHash, BATCH_COMPACT_TYPEHASH)); + assertTrue(allocator.isClaimAuthorized(claimHash, address(0), address(0), 0, 0, new uint256[2][](0), '')); + assertEq(registeredAmounts[0], defaultAmount / 2); + assertEq(registeredAmounts.length, 1); + assertEq(usdc.balanceOf(address(compact)), defaultAmount); + assertEq(compact.balanceOf(address(user), idsAndAmounts[0][0]), defaultAmount); + + assertEq(allocator.nonces(), 2); + } + + function test_allocateAndRegister_checkClaimHashNoWitness() public { + uint256[2][] memory idsAndAmounts = new uint256[2][](1); + idsAndAmounts[0][0] = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(0)); + idsAndAmounts[0][1] = 0; + + (bytes32 claimHash, uint256[] memory registeredAmounts, uint256 nonce) = allocator.allocateAndRegister{ + value: defaultAmount + }(user, idsAndAmounts, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, ''); + BatchCompact memory batch = _updateBatchCompact(batchCompact, idsAndAmounts, registeredAmounts, nonce); + + bytes32 createdHash = _toBatchCompactHash(batch); + assertEq(createdHash, claimHash); + assertTrue(allocator.isClaimAuthorized(createdHash, address(0), address(0), 0, 0, new uint256[2][](0), '')); + } + + function test_allocateAndRegister_checkClaimHashWitness() public { + uint256[2][] memory idsAndAmounts = new uint256[2][](1); + idsAndAmounts[0][0] = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(0)); + idsAndAmounts[0][1] = 0; + + bytes32 witness = keccak256(abi.encode(WITNESS_TYPEHASH, 1)); + + (bytes32 claimHash, uint256[] memory registeredAmounts, uint256 nonce) = allocator.allocateAndRegister{ + value: defaultAmount + }(user, idsAndAmounts, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH_WITH_WITNESS, witness); + BatchCompact memory batch = _updateBatchCompact(batchCompact, idsAndAmounts, registeredAmounts, nonce); + bytes32 createdHash = _toBatchCompactHashWithWitness(BATCH_COMPACT_TYPEHASH_WITH_WITNESS, batch, witness); + assertEq(createdHash, claimHash); + assertTrue(allocator.isClaimAuthorized(createdHash, address(0), address(0), 0, 0, new uint256[2][](0), '')); + } + + function test_allocateAndRegister_slot() public { + uint256[2][] memory idsAndAmounts = new uint256[2][](1); + idsAndAmounts[0][0] = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(0)); + idsAndAmounts[0][1] = 0; + + bytes32 witness = keccak256(abi.encode(WITNESS_TYPEHASH, 1)); + + (bytes32 claimHash,,) = allocator.allocateAndRegister{value: defaultAmount}( + user, idsAndAmounts, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH_WITH_WITNESS, witness + ); + + bytes32 claimSlot = keccak256(abi.encode(claimHash, 0x00)); + bytes32 claimSlotData = vm.load(address(allocator), claimSlot); + assertEq(claimSlotData, bytes32(uint256(1))); + } + + function test_isClaimAuthorized_unauthorized() public { + uint256[2][] memory idsAndAmounts = new uint256[2][](1); + idsAndAmounts[0][0] = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(0)); + idsAndAmounts[0][1] = 0; + + (bytes32 claimHash, uint256[] memory registeredAmounts, uint256 nonce) = allocator.allocateAndRegister{ + value: defaultAmount + }(user, idsAndAmounts, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, ''); + BatchCompact memory batch = _updateBatchCompact(batchCompact, idsAndAmounts, registeredAmounts, nonce); + + // Use the same batchCompact, but add a witness + bytes32 falseHash = _toBatchCompactHashWithWitness(BATCH_COMPACT_TYPEHASH_WITH_WITNESS, batch, bytes32(0)); + assertNotEq(falseHash, claimHash); + assertFalse(allocator.isClaimAuthorized(falseHash, address(0), address(0), 0, 0, new uint256[2][](0), '')); + } + + function test_isClaimAuthorized_signerZeroAddress() public { + // Create an arbitrary claim hash that has not been registered. + bytes32 claimHash = keccak256('invalid'); + assertEq(ecrecover(claimHash, 0, bytes32(0), bytes32(0)), address(0)); + + // Craft a 65-byte signature that will make ecrecover return the zero address: + // r = 0, s = 0, v = 0 (v not 27/28). + bytes memory invalidSignature = abi.encodePacked(bytes32(0), bytes32(0), uint8(0)); + + // Forcing address(0) as signer + uint256 signersSlot = 0x03; + vm.store(address(allocator), keccak256(abi.encode(address(0), signersSlot)), bytes32(uint256(1))); + + assertTrue(allocator.signers(address(0))); + + assertFalse( + allocator.isClaimAuthorized(claimHash, address(0), address(0), 0, 0, new uint256[2][](0), invalidSignature) + ); + } + + function test_isClaimAuthorized_withSigner_bytes64() public view { + uint256[2][] memory idsAndAmounts = new uint256[2][](1); + idsAndAmounts[0][0] = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(0)); + idsAndAmounts[0][1] = defaultAmount; + + bytes32 witness = keccak256(abi.encode(WITNESS_TYPEHASH, 1)); + BatchCompact memory batch = _updateBatchCompact(batchCompact, idsAndAmounts, 1); + + bytes32 claimHash = _toBatchCompactHashWithWitness(BATCH_COMPACT_TYPEHASH_WITH_WITNESS, batch, witness); + bytes32 digest = _toDigest(claimHash, compact.DOMAIN_SEPARATOR()); + (bytes32 r, bytes32 vs) = vm.signCompact(signerPrivateKey, digest); + bytes memory signature = abi.encodePacked(r, vs); + assertEq(signature.length, 64); + assertTrue( + allocator.isClaimAuthorized(claimHash, arbiter, user, 1, defaultExpiration, idsAndAmounts, signature) + ); + } + + function test_isClaimAuthorized_withSigner_bytes65() public view { + uint256[2][] memory idsAndAmounts = new uint256[2][](1); + idsAndAmounts[0][0] = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(0)); + idsAndAmounts[0][1] = defaultAmount; + + bytes32 witness = keccak256(abi.encode(WITNESS_TYPEHASH, 1)); + BatchCompact memory batch = _updateBatchCompact(batchCompact, idsAndAmounts, 1); + + bytes32 claimHash; + bytes memory signature; + { + claimHash = _toBatchCompactHashWithWitness(BATCH_COMPACT_TYPEHASH_WITH_WITNESS, batch, witness); + bytes32 digest = _toDigest(claimHash, compact.DOMAIN_SEPARATOR()); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPrivateKey, digest); + signature = abi.encodePacked(r, s, v); + } + assertEq(signature.length, 65); + assertTrue( + allocator.isClaimAuthorized(claimHash, arbiter, user, 1, defaultExpiration, idsAndAmounts, signature) + ); + } + + function test_isClaimAuthorized_invalidSignature() public { + uint256[2][] memory idsAndAmounts = new uint256[2][](1); + idsAndAmounts[0][0] = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(0)); + idsAndAmounts[0][1] = defaultAmount; + + bytes32 witness = keccak256(abi.encode(WITNESS_TYPEHASH, 1)); + BatchCompact memory batch = _updateBatchCompact(batchCompact, idsAndAmounts, 1); + + bytes32 claimHash = _toBatchCompactHashWithWitness(BATCH_COMPACT_TYPEHASH_WITH_WITNESS, batch, witness); + bytes32 digest = _toDigest(claimHash, compact.DOMAIN_SEPARATOR()); + + bytes memory signature; + { + (, uint256 attackerPK) = makeAddrAndKey('attacker'); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(attackerPK, digest); + signature = abi.encodePacked(r, s, v); + assertEq(signature.length, 65); + } + assertFalse( + allocator.isClaimAuthorized(claimHash, arbiter, user, 1, defaultExpiration, idsAndAmounts, signature) + ); + } + + function test_attest_revert_Unsupported() public { + uint256 id = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(usdc)); + address target = makeAddr('target'); + + assertEq(usdc.balanceOf(user), defaultAmount); + + vm.expectRevert(abi.encodeWithSelector(IHybridAllocator.Unsupported.selector)); + allocator.attest(signer, user, target, id, defaultAmount); + } + + function test_attest_revert_transferFailed() public { + uint256 id = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(usdc)); + address target = makeAddr('target'); + + assertEq(usdc.balanceOf(user), defaultAmount); + vm.startPrank(user); + usdc.approve(address(compact), defaultAmount); + compact.depositERC20(address(usdc), bytes12(bytes32(id)), defaultAmount, user); + + vm.expectRevert(abi.encodeWithSelector(IHybridAllocator.Unsupported.selector), address(allocator)); + compact.transfer(target, id, defaultAmount); + vm.stopPrank(); + } + + function test_authorizeClaim_revert_invalidCaller(address attacker) public { + vm.assume(attacker != address(compact)); + uint256[2][] memory idsAndAmounts = new uint256[2][](1); + idsAndAmounts[0][0] = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(0)); + idsAndAmounts[0][1] = 0; + + // Provide tokens + vm.prank(user); + usdc.transfer(address(allocator), defaultAmount); + assertEq(usdc.balanceOf(address(allocator)), defaultAmount); + assertEq(address(allocator).balance, 0); + + bytes32 witness = keccak256(abi.encode(WITNESS_TYPEHASH, 1)); + + vm.prank(user); + (bytes32 claimHash,,) = allocator.allocateAndRegister{value: defaultAmount}( + user, idsAndAmounts, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH_WITH_WITNESS, witness + ); + + vm.prank(attacker); + vm.expectRevert(abi.encodeWithSelector(IHybridAllocator.InvalidCaller.selector, attacker, address(compact))); + allocator.authorizeClaim(claimHash, address(0), address(0), 0, 0, new uint256[2][](0), ''); + } + + function test_revert_authorizeClaim_InvalidSignature(uint128 nonce) public { + uint256[2][] memory idsAndAmounts = new uint256[2][](2); + idsAndAmounts[0][0] = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(0)); + idsAndAmounts[0][1] = defaultAmount; + + idsAndAmounts[1][0] = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(usdc)); + idsAndAmounts[1][1] = defaultAmount; + + // Approve tokens + vm.prank(user); + usdc.approve(address(compact), defaultAmount); + + bytes32 witness = keccak256(abi.encode(WITNESS_TYPEHASH, 1)); + + bytes32 claimHash = _toBatchCompactHashWithWitness( + BATCH_COMPACT_TYPEHASH_WITH_WITNESS, + BatchCompact({ + arbiter: arbiter, + sponsor: user, + nonce: nonce, + expires: defaultExpiration, + commitments: _idsAndAmountsToCommitments(idsAndAmounts) + }), + witness + ); + + bytes32[2][] memory claimHashesAndTypehashes = new bytes32[2][](1); + claimHashesAndTypehashes[0][0] = claimHash; + claimHashesAndTypehashes[0][1] = BATCH_COMPACT_TYPEHASH_WITH_WITNESS; + + // Deposit and register + vm.prank(user); + compact.batchDepositAndRegisterMultiple{value: defaultAmount}(idsAndAmounts, claimHashesAndTypehashes); + + // Off chain signing the claim + bytes32 digest = _toDigest(claimHash, compact.DOMAIN_SEPARATOR()); + (address attacker, uint256 attackerPK) = makeAddrAndKey('attacker'); + (bytes32 r, bytes32 vs) = vm.signCompact(attackerPK, digest); + bytes memory allocatorData = abi.encodePacked(r, vs); + + BatchClaimComponent[] memory claims = new BatchClaimComponent[](2); + { + Component[] memory portions = new Component[](1); + portions[0] = Component({ + claimant: uint256(bytes32(abi.encodePacked(bytes12(0), attacker))), // indicating a withdrawal + amount: defaultAmount + }); + + claims[0] = + BatchClaimComponent({id: idsAndAmounts[0][0], allocatedAmount: defaultAmount, portions: portions}); + claims[1] = + BatchClaimComponent({id: idsAndAmounts[1][0], allocatedAmount: defaultAmount, portions: portions}); + } + + BatchClaim memory claim = BatchClaim({ + allocatorData: allocatorData, + sponsorSignature: '', + sponsor: user, + nonce: nonce, + expires: defaultExpiration, + witness: witness, + witnessTypestring: WITNESS_STRING, + claims: claims + }); + + vm.prank(arbiter); + vm.expectRevert(abi.encodeWithSelector(IHybridAllocator.InvalidSignature.selector)); + compact.batchClaim(claim); + } + + function test_authorizeClaim_success_onChain() public { + uint256[2][] memory idsAndAmounts = new uint256[2][](2); + idsAndAmounts[0][0] = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(0)); + idsAndAmounts[0][1] = 0; + + idsAndAmounts[1][0] = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(usdc)); + idsAndAmounts[1][1] = 0; + + // Provide tokens + vm.prank(user); + usdc.transfer(address(allocator), defaultAmount); + assertEq(usdc.balanceOf(address(allocator)), defaultAmount); + assertEq(address(allocator).balance, 0); + + bytes32 witness = keccak256(abi.encode(WITNESS_TYPEHASH, 1)); + + vm.prank(user); + (bytes32 claimHash,, uint256 nonce) = allocator.allocateAndRegister{value: defaultAmount}( + user, idsAndAmounts, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH_WITH_WITNESS, witness + ); + + address target = makeAddr('target'); + + bytes32 returnedClaimHash; + { + Component[] memory portions = new Component[](1); + portions[0] = Component({ + claimant: uint256(bytes32(abi.encodePacked(bytes12(0), target))), // indicating a withdrawal + amount: defaultAmount + }); + + BatchClaimComponent[] memory claims = new BatchClaimComponent[](2); + claims[0] = + BatchClaimComponent({id: idsAndAmounts[0][0], allocatedAmount: defaultAmount, portions: portions}); + claims[1] = + BatchClaimComponent({id: idsAndAmounts[1][0], allocatedAmount: defaultAmount, portions: portions}); + + BatchClaim memory claim = BatchClaim({ + allocatorData: '', + sponsorSignature: '', + sponsor: user, + nonce: nonce, + expires: defaultExpiration, + witness: witness, + witnessTypestring: WITNESS_STRING, + claims: claims + }); + vm.prank(arbiter); + returnedClaimHash = compact.batchClaim(claim); + assertEq(returnedClaimHash, claimHash); + } + + assertEq(usdc.balanceOf(address(compact)), 0, 'compact usdc balance should be 0'); + assertEq(usdc.balanceOf(address(user)), 0, 'user usdc balance should be 0'); + assertEq(usdc.balanceOf(address(target)), defaultAmount, 'target usdc balance should be defaultAmount'); + assertEq(address(compact).balance, 0, 'compact balance should be 0'); + assertEq(address(user).balance, 0, 'user balance should be 0'); + assertEq(address(target).balance, defaultAmount, 'target balance should be defaultAmount'); + assertEq(compact.balanceOf(address(user), idsAndAmounts[0][0]), 0, 'user eth compact balance of 0 should be 0'); + assertEq( + compact.balanceOf(address(target), idsAndAmounts[0][0]), 0, 'target eth compact balance of 0 should be 0' + ); + assertEq(compact.balanceOf(address(user), idsAndAmounts[1][0]), 0, 'user usdc compact balance of 0 should be 0'); + assertEq( + compact.balanceOf(address(target), idsAndAmounts[1][0]), 0, 'target usdc compact balance of 0 should be 0' + ); + } + + function test_authorizeClaim_success_offChain(uint128 nonce) public { + uint256[2][] memory idsAndAmounts = new uint256[2][](2); + idsAndAmounts[0][0] = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(0)); + idsAndAmounts[0][1] = defaultAmount; + + idsAndAmounts[1][0] = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(usdc)); + idsAndAmounts[1][1] = defaultAmount; + + // Approve tokens + vm.prank(user); + usdc.approve(address(compact), defaultAmount); + + bytes32 witness = keccak256(abi.encode(WITNESS_TYPEHASH, 1)); + + bytes32 claimHash = _toBatchCompactHashWithWitness( + BATCH_COMPACT_TYPEHASH_WITH_WITNESS, + BatchCompact({ + arbiter: arbiter, + sponsor: user, + nonce: nonce, + expires: defaultExpiration, + commitments: _idsAndAmountsToCommitments(idsAndAmounts) + }), + witness + ); + + bytes32[2][] memory claimHashesAndTypehashes = new bytes32[2][](1); + claimHashesAndTypehashes[0][0] = claimHash; + claimHashesAndTypehashes[0][1] = BATCH_COMPACT_TYPEHASH_WITH_WITNESS; + + // Deposit and register + vm.prank(user); + compact.batchDepositAndRegisterMultiple{value: defaultAmount}(idsAndAmounts, claimHashesAndTypehashes); + + // Off chain signing the claim + bytes32 digest = _toDigest(claimHash, compact.DOMAIN_SEPARATOR()); + (bytes32 r, bytes32 vs) = vm.signCompact(signerPrivateKey, digest); + bytes memory allocatorData = abi.encodePacked(r, vs); + + address target = makeAddr('target'); + + BatchClaimComponent[] memory claims = new BatchClaimComponent[](2); + { + Component[] memory portions = new Component[](1); + portions[0] = Component({ + claimant: uint256(bytes32(abi.encodePacked(bytes12(0), target))), // indicating a withdrawal + amount: defaultAmount + }); + + claims[0] = + BatchClaimComponent({id: idsAndAmounts[0][0], allocatedAmount: defaultAmount, portions: portions}); + claims[1] = + BatchClaimComponent({id: idsAndAmounts[1][0], allocatedAmount: defaultAmount, portions: portions}); + } + + BatchClaim memory claim = BatchClaim({ + allocatorData: allocatorData, + sponsorSignature: '', + sponsor: user, + nonce: nonce, + expires: defaultExpiration, + witness: witness, + witnessTypestring: WITNESS_STRING, + claims: claims + }); + + vm.prank(arbiter); + bytes32 returnedClaimHash = compact.batchClaim(claim); + assertEq(returnedClaimHash, claimHash); + + assertEq(usdc.balanceOf(address(compact)), 0, 'compact usdc balance should be 0'); + assertEq(usdc.balanceOf(address(user)), 0, 'user usdc balance should be 0'); + assertEq(usdc.balanceOf(address(target)), defaultAmount, 'target usdc balance should be defaultAmount'); + assertEq(address(compact).balance, 0, 'compact balance should be 0'); + assertEq(address(user).balance, 0, 'user balance should be 0'); + assertEq(address(target).balance, defaultAmount, 'target balance should be defaultAmount'); + assertEq(compact.balanceOf(address(user), idsAndAmounts[0][0]), 0, 'user eth compact balance of 0 should be 0'); + assertEq( + compact.balanceOf(address(target), idsAndAmounts[0][0]), 0, 'target eth compact balance of 0 should be 0' + ); + assertEq(compact.balanceOf(address(user), idsAndAmounts[1][0]), 0, 'user usdc compact balance of 0 should be 0'); + assertEq( + compact.balanceOf(address(target), idsAndAmounts[1][0]), 0, 'target usdc compact balance of 0 should be 0' + ); + } + + function test_authorizeClaim_registrationDeleted() public { + uint256[2][] memory idsAndAmounts = new uint256[2][](1); + idsAndAmounts[0][0] = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(0)); + idsAndAmounts[0][1] = 0; + + vm.prank(user); + (bytes32 claimHash,, uint256 nonce) = allocator.allocateAndRegister{value: defaultAmount}( + user, idsAndAmounts, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, '' + ); + + bytes32 digest = _toDigest(claimHash, compact.DOMAIN_SEPARATOR()); + (bytes32 r, bytes32 vs) = vm.signCompact(userPrivateKey, digest); + bytes memory sponsorSignature = abi.encodePacked(r, vs); + + address target = makeAddr('target'); + + Component[] memory portions = new Component[](1); + portions[0] = Component({ + claimant: uint256(bytes32(abi.encodePacked(bytes12(0), target))), // indicating a withdrawal + amount: defaultAmount + }); + + BatchClaimComponent[] memory claims = new BatchClaimComponent[](1); + claims[0] = BatchClaimComponent({id: idsAndAmounts[0][0], allocatedAmount: defaultAmount, portions: portions}); + + BatchClaim memory claim = BatchClaim({ + allocatorData: '', + sponsorSignature: sponsorSignature, + sponsor: user, + nonce: nonce, + expires: defaultExpiration, + witness: '', + witnessTypestring: '', + claims: claims + }); + + vm.prank(arbiter); + bytes32 returnedClaimHash = compact.batchClaim(claim); + assertEq(returnedClaimHash, claimHash); + + assertFalse(allocator.isClaimAuthorized(claimHash, address(0), address(0), 0, 0, new uint256[2][](0), '')); + } + + function test_addSigner_revert_InvalidSigner(address attacker) public { + vm.assume(attacker != address(0)); + vm.assume(attacker != signer); + vm.prank(attacker); + vm.expectRevert(abi.encodeWithSelector(IHybridAllocator.InvalidSigner.selector)); + allocator.addSigner(attacker); + assertEq(allocator.signerCount(), 1); + assertFalse(allocator.signers(attacker)); + } + + function test_addSigner_revert_signerIsZero() public { + vm.prank(signer); + vm.expectRevert(abi.encodeWithSelector(IHybridAllocator.InvalidSigner.selector)); + allocator.addSigner(address(0)); + assertEq(allocator.signerCount(), 1); + assertFalse(allocator.signers(address(0))); + } + + function test_addSigner_success(address newSigner) public { + vm.assume(newSigner != signer); + vm.assume(newSigner != address(0)); + vm.prank(signer); + allocator.addSigner(newSigner); + assertEq(allocator.signerCount(), 2); + assertTrue(allocator.signers(newSigner)); + assertTrue(allocator.signers(signer)); + } + + function test_removeSigner_revert_InvalidSigner(address attacker) public { + vm.assume(attacker != signer); + vm.prank(attacker); + vm.expectRevert(abi.encodeWithSelector(IHybridAllocator.InvalidSigner.selector)); + allocator.removeSigner(signer); + assertEq(allocator.signerCount(), 1); + assertTrue(allocator.signers(signer)); + } + + function test_removeSigner_revert_LastSigner() public { + vm.prank(signer); + vm.expectRevert(abi.encodeWithSelector(IHybridAllocator.LastSigner.selector)); + allocator.removeSigner(signer); + assertEq(allocator.signerCount(), 1); + assertTrue(allocator.signers(signer)); + } + + function test_removeSigner_success(address newSigner) public { + vm.assume(newSigner != signer); + vm.assume(newSigner != address(0)); + vm.prank(signer); + allocator.addSigner(newSigner); + assertEq(allocator.signerCount(), 2); + vm.prank(newSigner); + allocator.removeSigner(signer); + assertEq(allocator.signerCount(), 1); + assertFalse(allocator.signers(signer)); + assertTrue(allocator.signers(newSigner)); + } + + function test_removeSigner_success_deleteSelf(address newSigner) public { + vm.assume(newSigner != signer); + vm.assume(newSigner != address(0)); + vm.prank(signer); + allocator.addSigner(newSigner); + assertEq(allocator.signerCount(), 2); + vm.prank(newSigner); + allocator.removeSigner(newSigner); + assertEq(allocator.signerCount(), 1); + assertTrue(allocator.signers(signer)); + assertFalse(allocator.signers(newSigner)); + } + + function test_replaceSigner_revert_InvalidSigner(address attacker) public { + vm.assume(attacker != signer); + vm.prank(attacker); + vm.expectRevert(abi.encodeWithSelector(IHybridAllocator.InvalidSigner.selector)); + allocator.replaceSigner(attacker); + assertEq(allocator.signerCount(), 1); + assertFalse(allocator.signers(attacker)); + } + + function test_replaceSigner_revert_signerIsZero() public { + vm.prank(signer); + vm.expectRevert(abi.encodeWithSelector(IHybridAllocator.InvalidSigner.selector)); + allocator.replaceSigner(address(0)); + assertEq(allocator.signerCount(), 1); + assertFalse(allocator.signers(address(0))); + } + + function test_replaceSigner_success(address newSigner) public { + vm.assume(newSigner != signer); + vm.assume(newSigner != address(0)); + vm.prank(signer); + allocator.replaceSigner(newSigner); + assertEq(allocator.signerCount(), 1); + assertFalse(allocator.signers(signer)); + assertTrue(allocator.signers(newSigner)); + } + + function test_constructor_allowsPreRegisteredAllocator_create2() public { + HybridAllocatorFactory factory = new HybridAllocatorFactory(); + + bytes32 salt = keccak256('hybrid-allocator-pre-registered'); + bytes memory initCode = + abi.encodePacked(type(HybridAllocator).creationCode, abi.encode(address(compact), signer)); + bytes32 initCodeHash = keccak256(initCode); + + address expected = + address(uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), address(factory), salt, initCodeHash))))); + + bytes memory proof = abi.encodePacked(bytes1(0xff), address(factory), salt, initCodeHash); + + uint96 preId = compact.__registerAllocator(expected, proof); + assertEq(_toAllocatorId(expected), preId); + + address deployed = HybridAllocatorFactory(address(factory)).deploy(salt, address(compact), signer); + assertEq(deployed, expected); + + HybridAllocator newAllocator = HybridAllocator(deployed); + assertEq(newAllocator.ALLOCATOR_ID(), _toAllocatorId(deployed)); + } +} diff --git a/test/HybridERC7683.t.sol b/test/HybridERC7683.t.sol new file mode 100644 index 0000000..62be160 --- /dev/null +++ b/test/HybridERC7683.t.sol @@ -0,0 +1,744 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {IERC1271} from '@openzeppelin/contracts/interfaces/IERC1271.sol'; +import {ERC6909} from '@solady/tokens/ERC6909.sol'; +import {TheCompact} from '@uniswap/the-compact/TheCompact.sol'; +import {ITheCompact} from '@uniswap/the-compact/interfaces/ITheCompact.sol'; + +import {IdLib} from '@uniswap/the-compact/lib/IdLib.sol'; + +import {BatchClaim} from '@uniswap/the-compact/types/BatchClaims.sol'; +import {BatchClaimComponent, Component} from '@uniswap/the-compact/types/Components.sol'; +import {BatchCompact, COMPACT_TYPEHASH, LOCK_TYPEHASH, Lock} from '@uniswap/the-compact/types/EIP712Types.sol'; +import {ForcedWithdrawalStatus} from '@uniswap/the-compact/types/ForcedWithdrawalStatus.sol'; +import {ResetPeriod} from '@uniswap/the-compact/types/ResetPeriod.sol'; +import {Scope} from '@uniswap/the-compact/types/Scope.sol'; +import {Test} from 'forge-std/Test.sol'; + +import {TestHelper} from 'test/util/TestHelper.sol'; + +import {HybridERC7683} from 'src/allocators/HybridERC7683.sol'; + +import {Fill, Mandate, RecipientCallback} from '@uniswap/tribunal/types/TribunalStructs.sol'; +import {ERC7683AllocatorLib as ERC7683AL} from 'src/allocators/lib/ERC7683AllocatorLib.sol'; + +import {Tribunal} from '@uniswap/tribunal/Tribunal.sol'; +import { + COMPACT_TYPEHASH_WITH_MANDATE, + MANDATE_BATCH_COMPACT_TYPEHASH, + MANDATE_FILL_TYPEHASH, + MANDATE_RECIPIENT_CALLBACK_TYPEHASH, + MANDATE_TYPEHASH, + WITNESS_TYPESTRING as WITNESS_TYPESTRING_TRIBUNAL +} from '@uniswap/tribunal/types/TribunalTypeHashes.sol'; +import {IOriginSettler} from 'src/interfaces/ERC7683/IOriginSettler.sol'; + +import {IERC7683Allocator} from 'src/interfaces/IERC7683Allocator.sol'; +import {IHybridAllocator} from 'src/interfaces/IHybridAllocator.sol'; + +import {ERC20Mock} from 'src/test/ERC20Mock.sol'; +import { + CompactData, + GaslessCrossChainOrderData, + MocksSetup, + OnChainCrossChainOrderData +} from 'test/util/ERC7683TestHelper.sol'; + +contract MockAllocator is GaslessCrossChainOrderData, OnChainCrossChainOrderData { + HybridERC7683 hybridERC7683Allocator; + address signer; + uint256 signerPK; + + function setUp() public virtual override(GaslessCrossChainOrderData, OnChainCrossChainOrderData) { + (signer, signerPK) = makeAddrAndKey('signer'); + TheCompact compactContract_ = new TheCompact(); + hybridERC7683Allocator = new HybridERC7683(address(compactContract_), signer); + _setUp(address(hybridERC7683Allocator), compactContract_, 1 /* defaultNonce */ ); + super.setUp(); + } +} + +contract HybridERC7683_open is MockAllocator { + function test_revert_InvalidOrderDataType() public { + // Order data type is invalid + bytes32 falseOrderDataType = keccak256('false'); + IOriginSettler.OnchainCrossChainOrder memory onChainCrossChainOrder_ = _getOnChainCrossChainOrder(); + onChainCrossChainOrder_.orderDataType = falseOrderDataType; + + vm.prank(user); + vm.expectRevert( + abi.encodeWithSelector( + IERC7683Allocator.InvalidOrderDataType.selector, falseOrderDataType, ORDERDATA_ONCHAIN_TYPEHASH + ) + ); + hybridERC7683Allocator.open(onChainCrossChainOrder_); + } + + function test_revert_ManipulatedOrderData() public { + // Deposit tokens + vm.prank(user); + compactContract.depositERC20(address(usdc), usdcLockTag, defaultAmount, user); + + // register a claim + BatchCompact memory compact_ = _getCompact(); + (bytes32 mandateHash,) = _hashMandate(_getMandate()); + + bytes32 claimHash = _deriveClaimHash(compact_, mandateHash); + compactContract.register(claimHash, COMPACT_TYPEHASH_WITH_MANDATE); + + vm.stopPrank(); + + (IOriginSettler.OnchainCrossChainOrder memory onChainCrossChainOrder_) = _getOnChainCrossChainOrder(); + + // Manipulate the order data + uint256 outOfBounds = type(uint256).max; // uint256(type(uint64).max) + 1; + + bytes memory callData = abi.encodeWithSelector(IOriginSettler.open.selector, onChainCrossChainOrder_); + // 0x00 selector + // 0x24 OnchainCrossChainOrder.offset + // 0x44 OnchainCrossChainOrder.fillDeadline + // 0x64 OnchainCrossChainOrder.orderDataType + // 0x84 OnchainCrossChainOrder.orderData.offset + // 0xa4 OnchainCrossChainOrder.orderData.length + // 0xc4 OnchainCrossChainOrder.OrderDataOnChain.offset + // 0xe4 OnchainCrossChainOrder.OrderDataOnChain.Order.offset + + assembly ("memory-safe") { + mstore(add(callData, 0xe4), outOfBounds) + } + + vm.prank(user); + (bool success, bytes memory returnData) = address(hybridERC7683Allocator).call(callData); + assertEq(success, false); + assertEq(returnData.length, 0); + } + + function test_orderDataType() public view { + assertEq(ERC7683AL.ORDERDATA_GASLESS_TYPEHASH, ORDERDATA_GASLESS_TYPEHASH); + } + + function test_successful() public { + // Provide tokens for allocation + vm.prank(user); + usdc.transfer(address(hybridERC7683Allocator), defaultAmount); + + IOriginSettler.OnchainCrossChainOrder memory onChainCrossChainOrder_ = _getOnChainCrossChainOrder(); + IOriginSettler.Output[] memory maxSpent = new IOriginSettler.Output[](1); + IOriginSettler.Output[] memory minReceived = new IOriginSettler.Output[](1); + IOriginSettler.FillInstruction[] memory fillInstructions = new IOriginSettler.FillInstruction[](1); + maxSpent[0] = IOriginSettler.Output({ + token: bytes32(uint256(uint160(defaultOutputToken))), + amount: type(uint256).max, + recipient: bytes32(uint256(uint160(user))), + chainId: defaultOutputChainId + }); + minReceived[0] = IOriginSettler.Output({ + token: bytes32(uint256(uint160(address(usdc)))), + amount: defaultMinimumAmount, + recipient: bytes32(0), + chainId: block.chainid + }); + BatchCompact memory compact_ = _getCompact(); + Mandate memory mandate_ = _getMandate(); + Tribunal.BatchClaim memory claim = Tribunal.BatchClaim({ + chainId: block.chainid, + compact: compact_, + sponsorSignature: '', + allocatorSignature: '' + }); + fillInstructions[0] = IOriginSettler.FillInstruction({ + destinationChainId: defaultOutputChainId, + destinationSettler: bytes32(uint256(uint160(tribunal))), + originData: abi.encode(claim, mandate_.fills[0], adjuster, _buildFillHashes(mandate_)) + }); + + IOriginSettler.ResolvedCrossChainOrder memory resolvedCrossChainOrder = IOriginSettler.ResolvedCrossChainOrder({ + user: user, + originChainId: block.chainid, + openDeadline: uint32(compact_.expires), + fillDeadline: uint32(mandate_.fills[0].expires), + orderId: bytes32(defaultNonce), + maxSpent: maxSpent, + minReceived: minReceived, + fillInstructions: fillInstructions + }); + vm.prank(user); + vm.expectEmit(true, false, false, true, address(hybridERC7683Allocator)); + emit IOriginSettler.Open(bytes32(defaultNonce), resolvedCrossChainOrder); + hybridERC7683Allocator.open(onChainCrossChainOrder_); + } +} + +contract HybridERC7683_openFor is MockAllocator { + function test_revert_InvalidOrderDataType() public { + // Order data type is invalid + bytes32 falseOrderDataType = keccak256('false'); + + vm.prank(user); + vm.expectRevert( + abi.encodeWithSelector( + IERC7683Allocator.InvalidOrderDataType.selector, falseOrderDataType, ORDERDATA_GASLESS_TYPEHASH + ) + ); + + BatchCompact memory compact_ = _getCompact(); + Mandate memory mandate_ = _getMandate(); + (IOriginSettler.GaslessCrossChainOrder memory falseGaslessCrossChainOrder) = + _getGaslessCrossChainOrder(compact_, mandate_, true); + bytes memory signature = _hashAndSign(compact_, mandate_, address(compactContract), signerPK); + + falseGaslessCrossChainOrder.orderDataType = falseOrderDataType; + hybridERC7683Allocator.openFor(falseGaslessCrossChainOrder, signature, ''); + } + + function test_orderDataType() public view { + assertEq(ERC7683AL.ORDERDATA_ONCHAIN_TYPEHASH, ORDERDATA_ONCHAIN_TYPEHASH); + } + + function test_successful_userHimself() public { + // Provide tokens for allocation + vm.prank(user); + usdc.transfer(address(hybridERC7683Allocator), defaultAmount); + + IOriginSettler.Output[] memory maxSpent = new IOriginSettler.Output[](1); + IOriginSettler.Output[] memory minReceived = new IOriginSettler.Output[](1); + IOriginSettler.FillInstruction[] memory fillInstructions = new IOriginSettler.FillInstruction[](1); + maxSpent[0] = IOriginSettler.Output({ + token: bytes32(uint256(uint160(defaultOutputToken))), + amount: type(uint256).max, + recipient: bytes32(uint256(uint160(user))), + chainId: defaultOutputChainId + }); + minReceived[0] = IOriginSettler.Output({ + token: bytes32(uint256(uint160(address(usdc)))), + amount: defaultMinimumAmount, + recipient: bytes32(0), + chainId: block.chainid + }); + + BatchCompact memory compact_ = _getCompact(); + Mandate memory mandate_ = _getMandate(); + + Tribunal.BatchClaim memory claim = Tribunal.BatchClaim({ + chainId: block.chainid, + compact: compact_, + sponsorSignature: '', + allocatorSignature: '' + }); + fillInstructions[0] = IOriginSettler.FillInstruction({ + destinationChainId: defaultOutputChainId, + destinationSettler: bytes32(uint256(uint160(tribunal))), + originData: abi.encode(claim, mandate_.fills[0], adjuster, _buildFillHashes(mandate_)) + }); + + IOriginSettler.ResolvedCrossChainOrder memory resolvedCrossChainOrder = IOriginSettler.ResolvedCrossChainOrder({ + user: user, + originChainId: block.chainid, + openDeadline: uint32(compact_.expires), + fillDeadline: uint32(mandate_.fills[0].expires), + orderId: bytes32(defaultNonce), + maxSpent: maxSpent, + minReceived: minReceived, + fillInstructions: fillInstructions + }); + + (IOriginSettler.GaslessCrossChainOrder memory gaslessCrossChainOrder_) = + _getGaslessCrossChainOrder(compact_, mandate_, true); + + vm.prank(user); + vm.expectEmit(true, false, false, true, address(hybridERC7683Allocator)); + emit IOriginSettler.Open(bytes32(defaultNonce), resolvedCrossChainOrder); + hybridERC7683Allocator.openFor(gaslessCrossChainOrder_, '', ''); + } + + function test_successful_relayed() public { + // Provide tokens for allocation + vm.prank(user); + usdc.transfer(address(hybridERC7683Allocator), defaultAmount); + + BatchCompact memory compact_ = _getCompact(); + Mandate memory mandate_ = _getMandate(); + (IOriginSettler.GaslessCrossChainOrder memory gaslessCrossChainOrder_) = + _getGaslessCrossChainOrder(compact_, mandate_, true); + + IOriginSettler.Output[] memory maxSpent = new IOriginSettler.Output[](1); + IOriginSettler.Output[] memory minReceived = new IOriginSettler.Output[](1); + IOriginSettler.FillInstruction[] memory fillInstructions = new IOriginSettler.FillInstruction[](1); + maxSpent[0] = IOriginSettler.Output({ + token: bytes32(uint256(uint160(defaultOutputToken))), + amount: type(uint256).max, + recipient: bytes32(uint256(uint160(user))), + chainId: defaultOutputChainId + }); + minReceived[0] = IOriginSettler.Output({ + token: bytes32(uint256(uint160(address(usdc)))), + amount: defaultMinimumAmount, + recipient: bytes32(0), + chainId: block.chainid + }); + Tribunal.BatchClaim memory claim = Tribunal.BatchClaim({ + chainId: block.chainid, + compact: compact_, + sponsorSignature: '', + allocatorSignature: '' + }); + fillInstructions[0] = IOriginSettler.FillInstruction({ + destinationChainId: defaultOutputChainId, + destinationSettler: bytes32(uint256(uint160(tribunal))), + originData: abi.encode(claim, mandate_.fills[0], adjuster, _buildFillHashes(mandate_)) + }); + + IOriginSettler.ResolvedCrossChainOrder memory resolvedCrossChainOrder = IOriginSettler.ResolvedCrossChainOrder({ + user: user, + originChainId: block.chainid, + openDeadline: uint32(compact_.expires), + fillDeadline: uint32(mandate_.fills[0].expires), + orderId: bytes32(defaultNonce), + maxSpent: maxSpent, + minReceived: minReceived, + fillInstructions: fillInstructions + }); + vm.prank(makeAddr('filler')); + vm.expectEmit(true, false, false, true, address(hybridERC7683Allocator)); + emit IOriginSettler.Open(bytes32(defaultNonce), resolvedCrossChainOrder); + hybridERC7683Allocator.openFor(gaslessCrossChainOrder_, '', ''); + } +} + +contract HybridERC7683_authorizeClaim is MockAllocator { + function test_revert_InvalidCaller() public { + vm.expectRevert( + abi.encodeWithSelector(IHybridAllocator.InvalidCaller.selector, address(this), address(compactContract)) + ); + hybridERC7683Allocator.authorizeClaim(bytes32(0), address(0), address(0), 0, 0, new uint256[2][](0), bytes('')); + } + + function test_successful_onChainClaim() public { + // Provide tokens for allocation + vm.prank(user); + usdc.transfer(address(hybridERC7683Allocator), defaultAmount); + + IOriginSettler.OnchainCrossChainOrder memory onChainCrossChainOrder_ = _getOnChainCrossChainOrder(); + + BatchCompact memory compact_ = _getCompact(); + Mandate memory mandate_ = _getMandate(); + + vm.prank(user); + hybridERC7683Allocator.open(onChainCrossChainOrder_); + + address filler = makeAddr('filler'); + + BatchClaimComponent[] memory batchClaimComponents = new BatchClaimComponent[](1); + { + Component[] memory components = new Component[](1); + components[0] = Component({claimant: uint256(uint160(filler)), amount: defaultAmount}); + BatchClaimComponent memory batchClaimComponent = + BatchClaimComponent({id: usdcId, allocatedAmount: defaultAmount, portions: components}); + batchClaimComponents[0] = batchClaimComponent; + } + (bytes32 mandateHash,) = _hashMandate(mandate_); + BatchClaim memory claim = BatchClaim({ + allocatorData: '', + sponsorSignature: '', + sponsor: user, + nonce: compact_.nonce, + expires: compact_.expires, + witness: mandateHash, + witnessTypestring: WITNESS_TYPESTRING_TRIBUNAL, + claims: batchClaimComponents + }); + vm.prank(arbiter); + compactContract.batchClaim(claim); + + assertEq(compactContract.balanceOf(user, usdcId), 0); + assertEq(usdc.balanceOf(filler), defaultAmount); + } + + function test_successful_signatureClaim() public { + // Provide tokens for allocation + vm.prank(user); + usdc.transfer(address(hybridERC7683Allocator), defaultAmount); + + BatchCompact memory compact_ = _getCompact(); + Mandate memory mandate_ = _getMandate(); + (IOriginSettler.GaslessCrossChainOrder memory gaslessCrossChainOrder_) = + _getGaslessCrossChainOrder(compact_, mandate_, true); + gaslessCrossChainOrder_ = _manipulateDeposit(gaslessCrossChainOrder_, true); + bytes memory sponsorSignature = _hashAndSign(compact_, mandate_, address(compactContract), signerPK); + + (bytes32 mandateHash,) = _hashMandate(mandate_); + + vm.prank(user); + hybridERC7683Allocator.openFor(gaslessCrossChainOrder_, sponsorSignature, ''); + + address filler = makeAddr('filler'); + + Component[] memory components = new Component[](1); + components[0] = Component({claimant: uint256(uint160(filler)), amount: defaultAmount}); + BatchClaimComponent memory batchClaimComponent = + BatchClaimComponent({id: usdcId, allocatedAmount: defaultAmount, portions: components}); + BatchClaimComponent[] memory batchClaimComponents = new BatchClaimComponent[](1); + batchClaimComponents[0] = batchClaimComponent; + BatchClaim memory claim = BatchClaim({ + allocatorData: '', + sponsorSignature: '', + sponsor: user, + nonce: defaultNonce, + expires: compact_.expires, + witness: mandateHash, + witnessTypestring: WITNESS_TYPESTRING_TRIBUNAL, + claims: batchClaimComponents + }); + vm.prank(arbiter); + compactContract.batchClaim(claim); + + assertEq(compactContract.balanceOf(user, usdcId), 0); + assertEq(usdc.balanceOf(filler), defaultAmount); + } + + function test_successful_signatureOnly() public { + // Provide tokens for allocation + vm.prank(user); + usdc.approve(address(compactContract), defaultAmount); + + BatchCompact memory compact_ = _getCompact(); + Mandate memory mandate_ = _getMandate(); + bytes32 claimHash = _deriveClaimHash(compact_, mandate_); + + vm.prank(user); + compactContract.depositERC20AndRegister( + address(usdc), usdcLockTag, defaultAmount, claimHash, COMPACT_TYPEHASH_WITH_MANDATE + ); + + address filler = makeAddr('filler'); + + // Sign with signer + bytes memory allocatorSignature = _hashAndSign(compact_, mandate_, address(compactContract), signerPK); + + BatchClaimComponent[] memory batchClaimComponents = new BatchClaimComponent[](1); + { + Component[] memory components = new Component[](1); + components[0] = Component({claimant: uint256(uint160(filler)), amount: defaultAmount}); + BatchClaimComponent memory batchClaimComponent = + BatchClaimComponent({id: usdcId, allocatedAmount: defaultAmount, portions: components}); + batchClaimComponents[0] = batchClaimComponent; + } + (bytes32 mandateHash,) = _hashMandate(mandate_); + BatchClaim memory claim = BatchClaim({ + allocatorData: allocatorSignature, + sponsorSignature: '', + sponsor: user, + nonce: defaultNonce, + expires: compact_.expires, + witness: mandateHash, + witnessTypestring: WITNESS_TYPESTRING_TRIBUNAL, + claims: batchClaimComponents + }); + + vm.prank(arbiter); + compactContract.batchClaim(claim); + } + + function test_revert_InvalidSignature() public { + // Provide tokens for allocation + vm.prank(user); + usdc.approve(address(compactContract), defaultAmount); + + BatchCompact memory compact_ = _getCompact(); + Mandate memory mandate_ = _getMandate(); + bytes32 claimHash = _deriveClaimHash(compact_, mandate_); + + vm.prank(user); + compactContract.depositERC20AndRegister( + address(usdc), usdcLockTag, defaultAmount, claimHash, COMPACT_TYPEHASH_WITH_MANDATE + ); + + address filler = makeAddr('filler'); + + // Sign with wrong signer + bytes memory allocatorSignature = _hashAndSign(compact_, mandate_, address(compactContract), attackerPK); + + BatchClaimComponent[] memory batchClaimComponents = new BatchClaimComponent[](1); + { + Component[] memory components = new Component[](1); + components[0] = Component({claimant: uint256(uint160(filler)), amount: defaultAmount}); + BatchClaimComponent memory batchClaimComponent = + BatchClaimComponent({id: usdcId, allocatedAmount: defaultAmount, portions: components}); + batchClaimComponents[0] = batchClaimComponent; + } + (bytes32 mandateHash,) = _hashMandate(mandate_); + BatchClaim memory claim = BatchClaim({ + allocatorData: allocatorSignature, // signed by attacker + sponsorSignature: '', + sponsor: user, + nonce: defaultNonce, + expires: compact_.expires, + witness: mandateHash, + witnessTypestring: WITNESS_TYPESTRING_TRIBUNAL, + claims: batchClaimComponents + }); + + vm.prank(arbiter); + vm.expectRevert(abi.encodeWithSelector(IHybridAllocator.InvalidSignature.selector)); + compactContract.batchClaim(claim); + } + + function test_revert_InvalidSignature_length() public { + // Provide tokens for allocation + vm.prank(user); + usdc.approve(address(compactContract), defaultAmount); + + BatchCompact memory compact_ = _getCompact(); + Mandate memory mandate_ = _getMandate(); + bytes32 claimHash = _deriveClaimHash(compact_, mandate_); + + vm.prank(user); + compactContract.depositERC20AndRegister( + address(usdc), usdcLockTag, defaultAmount, claimHash, COMPACT_TYPEHASH_WITH_MANDATE + ); + + address filler = makeAddr('filler'); + + // Sign with signer and create the wrong length + bytes memory allocatorSignature = + abi.encodePacked(_hashAndSign(compact_, mandate_, address(compactContract), signerPK), uint8(0)); + + BatchClaimComponent[] memory batchClaimComponents = new BatchClaimComponent[](1); + { + Component[] memory components = new Component[](1); + components[0] = Component({claimant: uint256(uint160(filler)), amount: defaultAmount}); + BatchClaimComponent memory batchClaimComponent = + BatchClaimComponent({id: usdcId, allocatedAmount: defaultAmount, portions: components}); + batchClaimComponents[0] = batchClaimComponent; + } + (bytes32 mandateHash,) = _hashMandate(mandate_); + BatchClaim memory claim = BatchClaim({ + allocatorData: allocatorSignature, // allocator signature with a length of 66 bytes + sponsorSignature: '', + sponsor: user, + nonce: defaultNonce, + expires: compact_.expires, + witness: mandateHash, + witnessTypestring: WITNESS_TYPESTRING_TRIBUNAL, + claims: batchClaimComponents + }); + + vm.prank(arbiter); + vm.expectRevert(abi.encodeWithSelector(IHybridAllocator.InvalidSignature.selector)); + compactContract.batchClaim(claim); + } +} + +contract HybridERC7683_resolveFor is MockAllocator { + function test_revert_InvalidOrderDataType() public { + BatchCompact memory compact_ = _getCompact(); + Mandate memory mandate_ = _getMandate(); + (IOriginSettler.GaslessCrossChainOrder memory gaslessCrossChainOrder_) = + _getGaslessCrossChainOrder(compact_, mandate_, true); + bytes32 falseOrderDataType = keccak256('false'); + gaslessCrossChainOrder_.orderDataType = falseOrderDataType; + vm.expectRevert( + abi.encodeWithSelector( + IERC7683Allocator.InvalidOrderDataType.selector, falseOrderDataType, ORDERDATA_GASLESS_TYPEHASH + ) + ); + hybridERC7683Allocator.resolveFor(gaslessCrossChainOrder_, ''); + } + + function test_resolveFor_successful() public { + // Provide tokens for allocation so allocator holds funds + vm.prank(user); + usdc.transfer(address(hybridERC7683Allocator), defaultAmount); + + IOriginSettler.Output[] memory maxSpent = new IOriginSettler.Output[](1); + IOriginSettler.Output[] memory minReceived = new IOriginSettler.Output[](1); + IOriginSettler.FillInstruction[] memory fillInstructions = new IOriginSettler.FillInstruction[](1); + maxSpent[0] = IOriginSettler.Output({ + token: bytes32(uint256(uint160(defaultOutputToken))), + amount: type(uint256).max, + recipient: bytes32(uint256(uint160(user))), + chainId: defaultOutputChainId + }); + minReceived[0] = IOriginSettler.Output({ + token: bytes32(uint256(uint160(address(usdc)))), + amount: defaultMinimumAmount, + recipient: bytes32(0), + chainId: block.chainid + }); + + BatchCompact memory compact_ = _getCompact(); + Mandate memory mandate_ = _getMandate(); + Tribunal.BatchClaim memory claim = Tribunal.BatchClaim({ + chainId: block.chainid, + compact: compact_, + sponsorSignature: '', + allocatorSignature: '' + }); + fillInstructions[0] = IOriginSettler.FillInstruction({ + destinationChainId: defaultOutputChainId, + destinationSettler: bytes32(uint256(uint160(tribunal))), + originData: abi.encode(claim, mandate_.fills[0], adjuster, _buildFillHashes(mandate_)) + }); + + IOriginSettler.ResolvedCrossChainOrder memory expected = IOriginSettler.ResolvedCrossChainOrder({ + user: user, + originChainId: block.chainid, + openDeadline: uint32(compact_.expires), + fillDeadline: uint32(mandate_.fills[0].expires), + orderId: bytes32(defaultNonce), + maxSpent: maxSpent, + minReceived: minReceived, + fillInstructions: fillInstructions + }); + + IOriginSettler.GaslessCrossChainOrder memory gaslessCrossChainOrder_ = + _getGaslessCrossChainOrder(compact_, mandate_, true); + + vm.prank(user); + IOriginSettler.ResolvedCrossChainOrder memory resolved = + hybridERC7683Allocator.resolveFor(gaslessCrossChainOrder_, ''); + assertEq(resolved.user, expected.user); + assertEq(resolved.originChainId, expected.originChainId); + assertEq(resolved.openDeadline, expected.openDeadline); + assertEq(resolved.fillDeadline, expected.fillDeadline); + assertEq(resolved.orderId, expected.orderId); + assertEq(resolved.maxSpent.length, expected.maxSpent.length); + assertEq(resolved.maxSpent[0].token, expected.maxSpent[0].token); + assertEq(resolved.maxSpent[0].amount, expected.maxSpent[0].amount); + assertEq(resolved.maxSpent[0].recipient, expected.maxSpent[0].recipient); + assertEq(resolved.maxSpent[0].chainId, expected.maxSpent[0].chainId); + assertEq(resolved.minReceived.length, expected.minReceived.length); + assertEq(resolved.minReceived[0].token, expected.minReceived[0].token); + assertEq(resolved.minReceived[0].amount, expected.minReceived[0].amount); + assertEq(resolved.minReceived[0].recipient, expected.minReceived[0].recipient); + assertEq(resolved.minReceived[0].chainId, expected.minReceived[0].chainId); + assertEq(resolved.fillInstructions.length, expected.fillInstructions.length); + assertEq(resolved.fillInstructions[0].destinationChainId, expected.fillInstructions[0].destinationChainId); + assertEq(resolved.fillInstructions[0].destinationSettler, expected.fillInstructions[0].destinationSettler); + assertEq(resolved.fillInstructions[0].originData, expected.fillInstructions[0].originData); + } +} + +contract HybridERC7683_resolve is MockAllocator { + function test_revert_InvalidOrderDataType() public { + IOriginSettler.OnchainCrossChainOrder memory onChainCrossChainOrder_ = _getOnChainCrossChainOrder(); + onChainCrossChainOrder_.orderDataType = keccak256('false'); + vm.expectRevert( + abi.encodeWithSelector( + IERC7683Allocator.InvalidOrderDataType.selector, + onChainCrossChainOrder_.orderDataType, + ORDERDATA_ONCHAIN_TYPEHASH + ) + ); + hybridERC7683Allocator.resolve(onChainCrossChainOrder_); + } + + function test_resolve_successful() public { + IOriginSettler.Output[] memory maxSpent = new IOriginSettler.Output[](1); + IOriginSettler.Output[] memory minReceived = new IOriginSettler.Output[](1); + IOriginSettler.FillInstruction[] memory fillInstructions = new IOriginSettler.FillInstruction[](1); + maxSpent[0] = IOriginSettler.Output({ + token: bytes32(uint256(uint160(defaultOutputToken))), + amount: type(uint256).max, + recipient: bytes32(uint256(uint160(user))), + chainId: defaultOutputChainId + }); + minReceived[0] = IOriginSettler.Output({ + token: bytes32(uint256(uint160(address(usdc)))), + amount: defaultMinimumAmount, + recipient: bytes32(0), + chainId: block.chainid + }); + BatchCompact memory compact_ = _getCompact(); + Mandate memory mandate_ = _getMandate(); + Tribunal.BatchClaim memory claim = Tribunal.BatchClaim({ + chainId: block.chainid, + compact: compact_, + sponsorSignature: '', + allocatorSignature: '' + }); + fillInstructions[0] = IOriginSettler.FillInstruction({ + destinationChainId: defaultOutputChainId, + destinationSettler: bytes32(uint256(uint160(tribunal))), + originData: abi.encode(claim, mandate_.fills[0], adjuster, _buildFillHashes(mandate_)) + }); + + IOriginSettler.ResolvedCrossChainOrder memory resolvedCrossChainOrder = IOriginSettler.ResolvedCrossChainOrder({ + user: user, + originChainId: block.chainid, + openDeadline: uint32(compact_.expires), + fillDeadline: uint32(mandate_.fills[0].expires), + orderId: bytes32(defaultNonce), + maxSpent: maxSpent, + minReceived: minReceived, + fillInstructions: fillInstructions + }); + IOriginSettler.OnchainCrossChainOrder memory onChainCrossChainOrder_ = + _getOnChainCrossChainOrder(compact_, mandate_); + vm.prank(user); + IOriginSettler.ResolvedCrossChainOrder memory resolved = hybridERC7683Allocator.resolve(onChainCrossChainOrder_); + assertEq(resolved.user, resolvedCrossChainOrder.user); + assertEq(resolved.originChainId, resolvedCrossChainOrder.originChainId); + assertEq(resolved.openDeadline, resolvedCrossChainOrder.openDeadline); + assertEq(resolved.fillDeadline, resolvedCrossChainOrder.fillDeadline); + assertEq(resolved.orderId, resolvedCrossChainOrder.orderId); + assertEq(resolved.maxSpent.length, resolvedCrossChainOrder.maxSpent.length); + assertEq(resolved.maxSpent[0].token, resolvedCrossChainOrder.maxSpent[0].token); + assertEq(resolved.maxSpent[0].amount, resolvedCrossChainOrder.maxSpent[0].amount); + assertEq(resolved.maxSpent[0].recipient, resolvedCrossChainOrder.maxSpent[0].recipient); + assertEq(resolved.maxSpent[0].chainId, resolvedCrossChainOrder.maxSpent[0].chainId); + assertEq(resolved.minReceived.length, resolvedCrossChainOrder.minReceived.length); + assertEq(resolved.minReceived[0].token, resolvedCrossChainOrder.minReceived[0].token); + assertEq(resolved.minReceived[0].amount, resolvedCrossChainOrder.minReceived[0].amount); + assertEq(resolved.minReceived[0].recipient, resolvedCrossChainOrder.minReceived[0].recipient); + assertEq(resolved.minReceived[0].chainId, resolvedCrossChainOrder.minReceived[0].chainId); + assertEq(resolved.fillInstructions.length, resolvedCrossChainOrder.fillInstructions.length); + assertEq( + resolved.fillInstructions[0].destinationChainId, + resolvedCrossChainOrder.fillInstructions[0].destinationChainId + ); + assertEq( + resolved.fillInstructions[0].destinationSettler, + resolvedCrossChainOrder.fillInstructions[0].destinationSettler + ); + assertEq(resolved.fillInstructions[0].originData, resolvedCrossChainOrder.fillInstructions[0].originData); + } +} + +contract HybridERC7683_hybridAllocatorInheritance is MockAllocator { + function test_inheritsHybridAllocatorFunctionality() public view { + // Test that it properly inherits from HybridAllocator + assertEq(hybridERC7683Allocator.nonces(), 0); + assertEq(hybridERC7683Allocator.signerCount(), 1); + assertTrue(hybridERC7683Allocator.signers(signer)); + assertEq(hybridERC7683Allocator.ALLOCATOR_ID(), _toAllocatorId(address(hybridERC7683Allocator))); + } + + function test_allocateAndRegister() public { + uint256[2][] memory idsAndAmounts = new uint256[2][](1); + idsAndAmounts[0][0] = usdcId; + idsAndAmounts[0][1] = defaultAmount; + + // Provide tokens + vm.prank(user); + usdc.transfer(address(hybridERC7683Allocator), defaultAmount); + + (bytes32 witness,) = _hashMandate(_getMandate()); + + (bytes32 claimHash, uint256[] memory registeredAmounts, uint256 nonce) = hybridERC7683Allocator + .allocateAndRegister( + user, idsAndAmounts, arbiter, _getClaimExpiration(), COMPACT_TYPEHASH_WITH_MANDATE, witness + ); + + assertTrue(compactContract.isRegistered(user, claimHash, COMPACT_TYPEHASH_WITH_MANDATE)); + assertTrue( + hybridERC7683Allocator.isClaimAuthorized(claimHash, address(0), address(0), 0, 0, new uint256[2][](0), '') + ); + assertEq(registeredAmounts[0], defaultAmount); + assertEq(usdc.balanceOf(address(compactContract)), defaultAmount); + assertEq(compactContract.balanceOf(address(user), idsAndAmounts[0][0]), defaultAmount); + assertEq(nonce, 1); + } +} diff --git a/test/OnChainAllocator.t.sol b/test/OnChainAllocator.t.sol new file mode 100644 index 0000000..419fa88 --- /dev/null +++ b/test/OnChainAllocator.t.sol @@ -0,0 +1,1335 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +/* + Tests for OnChainAllocator.sol + These largely mirror the structure & style of HybridAllocator.t.sol while + focussing on the purely-on-chain allocation flow (no signature logic except + the permit-style path in allocateFor). +*/ + +import {Test} from 'forge-std/Test.sol'; + +import {ERC20Mock} from 'src/test/ERC20Mock.sol'; + +import {TheCompact} from '@uniswap/the-compact/TheCompact.sol'; + +import {IAllocator} from '@uniswap/the-compact/interfaces/IAllocator.sol'; +import {ITheCompact} from '@uniswap/the-compact/interfaces/ITheCompact.sol'; + +import {IOnChainAllocation} from '@uniswap/the-compact/interfaces/IOnChainAllocation.sol'; +import {OnChainAllocator} from 'src/allocators/OnChainAllocator.sol'; +import {IOnChainAllocator} from 'src/interfaces/IOnChainAllocator.sol'; + +import {BATCH_COMPACT_TYPEHASH, LOCK_TYPEHASH, Lock} from '@uniswap/the-compact/types/EIP712Types.sol'; + +import {ERC6909} from '@solady/tokens/ERC6909.sol'; +import {ResetPeriod} from '@uniswap/the-compact/types/ResetPeriod.sol'; +import {Scope} from '@uniswap/the-compact/types/Scope.sol'; + +import {AllocatorLib} from 'src/allocators/lib/AllocatorLib.sol'; +import {OnChainAllocationCaller} from 'src/test/OnChainAllocationCaller.sol'; +import {TestHelper} from 'test/util/TestHelper.sol'; + +contract OnChainAllocatorFactory { + function deploy(bytes32 salt, address compact) external returns (address) { + return address(new OnChainAllocator{salt: salt}(compact)); + } +} + +contract OnChainAllocatorTest is Test, TestHelper { + TheCompact internal compact; + OnChainAllocator internal allocator; + + address internal arbiter; + address internal user; + uint256 internal userPK; + + ERC20Mock internal usdc; + ERC20Mock internal dai; + + address internal recipient; + address internal caller; + uint256 internal callerPK; + + OnChainAllocationCaller internal allocationCaller; + + uint256 internal defaultAmount; + uint32 internal defaultExpiration; + + uint256 defaultNonce; + + function setUp() public { + compact = new TheCompact(); + arbiter = makeAddr('arbiter'); + (user, userPK) = makeAddrAndKey('user'); + allocator = new OnChainAllocator(address(compact)); + + usdc = new ERC20Mock('USDC', 'USDC'); + dai = new ERC20Mock('DAI', 'DAI'); + + recipient = makeAddr('recipient'); + (caller, callerPK) = makeAddrAndKey('caller'); + allocationCaller = new OnChainAllocationCaller(address(allocator), address(compact)); + deal(user, 1 ether); + usdc.mint(user, 1 ether); + + defaultAmount = 1 ether; + defaultExpiration = uint32(block.timestamp + 300); // 5 minutes fits 10-minute reset period + defaultNonce = _composeNonceUint(user, 1); + } + + /* --------------------------------------------------------------------- */ + /* Helpers */ + /* --------------------------------------------------------------------- */ + + function _composeNonceUint(address a, uint256 nonce) internal pure returns (uint256) { + return (uint256(uint160(a)) << 96) | nonce; + } + + function _commitmentsHash(Lock[] memory commitments) internal pure returns (bytes32) { + bytes32[] memory hashes = new bytes32[](commitments.length); + for (uint256 i = 0; i < commitments.length; i++) { + hashes[i] = keccak256( + abi.encode(LOCK_TYPEHASH, commitments[i].lockTag, commitments[i].token, commitments[i].amount) + ); + } + return keccak256(abi.encodePacked(hashes)); + } + + function _makeLock(address token, uint256 amount) internal view returns (Lock memory l) { + bytes12 lockTag = _toLockTag(address(allocator), Scope.Multichain, ResetPeriod.TenMinutes); + l = Lock({lockTag: lockTag, token: token, amount: amount}); + } + + function _createClaimHash( + address sponsor, + address arbiter_, + uint256 nonce, + uint256 expiration, + Lock[] memory commitments, + bytes32 witness + ) internal pure returns (bytes32) { + bytes32 commitmentsHash = _commitmentsHash(commitments); + if (witness == bytes32(0)) { + return keccak256(abi.encode(BATCH_COMPACT_TYPEHASH, arbiter_, sponsor, nonce, expiration, commitmentsHash)); + } else { + return keccak256( + abi.encode( + BATCH_COMPACT_TYPEHASH_WITH_WITNESS, arbiter_, sponsor, nonce, expiration, commitmentsHash, witness + ) + ); + } + } + + /* --------------------------------------------------------------------- */ + /* allocate() */ + /* --------------------------------------------------------------------- */ + + function test_allocate_revert_InvalidExpiration() public { + Lock[] memory commitments = new Lock[](1); + commitments[0] = _makeLock(address(0), defaultAmount); + + // Deposit native token to Compact first so allocation is backed + bytes12 lockTag = commitments[0].lockTag; + vm.prank(user); + compact.depositNative{value: defaultAmount}(lockTag, user); + + uint256 expiration = vm.getBlockTimestamp() + 600; // 10 min reset period + + vm.prank(user); + vm.expectRevert( + abi.encodeWithSelector(IOnChainAllocator.InvalidExpiration.selector, expiration, expiration - 1) + ); + allocator.allocate(commitments, arbiter, uint32(expiration), BATCH_COMPACT_TYPEHASH, bytes32(0)); + } + + function test_allocate_revert_ForceWithdrawalAvailable() public { + Lock[] memory commitments = new Lock[](1); + commitments[0] = _makeLock(address(0), defaultAmount); + + // Deposit native token to Compact first so allocation is backed + bytes12 lockTag = commitments[0].lockTag; + vm.prank(user); + compact.depositNative{value: defaultAmount}(lockTag, user); + + // Enable forced withdrawal + vm.prank(user); + (uint256 withdrawableAt) = compact.enableForcedWithdrawal( + _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(0)) + ); + + vm.prank(user); + vm.expectRevert( + abi.encodeWithSelector(IOnChainAllocator.ForceWithdrawalAvailable.selector, withdrawableAt, withdrawableAt) + ); + allocator.allocate(commitments, arbiter, uint32(withdrawableAt), BATCH_COMPACT_TYPEHASH, bytes32(0)); + + vm.prank(user); + allocator.allocate(commitments, arbiter, uint32(withdrawableAt - 1), BATCH_COMPACT_TYPEHASH, bytes32(0)); + } + + function test_allocate_revert_InvalidAmount() public { + Lock[] memory commitments = new Lock[](1); + commitments[0] = _makeLock(address(0), uint256(type(uint224).max) + 1); + + vm.prank(user); + vm.expectRevert(abi.encodeWithSelector(IOnChainAllocator.InvalidAmount.selector, commitments[0].amount)); + allocator.allocate(commitments, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, bytes32(0)); + } + + function test_allocate_revert_InsufficientBalance() public { + Lock[] memory commitments = new Lock[](1); + commitments[0] = _makeLock(address(0), defaultAmount); + + // No deposit made for native token – balance is zero, should revert. + vm.prank(user); + vm.expectRevert( + abi.encodeWithSelector( + IOnChainAllocator.InsufficientBalance.selector, + user, + _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(0)), + 0, + defaultAmount + ) + ); + allocator.allocate(commitments, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, bytes32(0)); + } + + function test_allocate_revert_InvalidAllocator() public { + Lock[] memory commitments = new Lock[](1); + commitments[0] = _makeLock(address(0), defaultAmount); + commitments[0].lockTag = bytes12(commitments[0].lockTag & bytes12(0x110000000000000000000000)); + + vm.prank(user); + vm.expectRevert( + abi.encodeWithSelector(IOnChainAllocator.InvalidAllocator.selector, 0, allocator.ALLOCATOR_ID()) + ); + allocator.allocate(commitments, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, bytes32(0)); + } + + function test_allocate_success_nativeToken() public { + Lock[] memory commitments = new Lock[](1); + commitments[0] = _makeLock(address(0), defaultAmount); + + // Deposit native token to Compact first so allocation is backed + bytes12 lockTag = commitments[0].lockTag; + vm.prank(user); + compact.depositNative{value: defaultAmount}(lockTag, user); + + vm.prank(user); + (bytes32 claimHash, uint256 nonce) = + allocator.allocate(commitments, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, bytes32(0)); + vm.snapshotGasLastCall('allocate_native'); + + uint256[2][] memory idsAndAmounts = new uint256[2][](1); + idsAndAmounts[0][0] = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(0)); + idsAndAmounts[0][1] = defaultAmount; + + assertEq(nonce, defaultNonce); + assertTrue(allocator.isClaimAuthorized(claimHash, arbiter, user, nonce, defaultExpiration, idsAndAmounts, '')); + } + + function test_allocate_success_erc20() public { + Lock[] memory commitments = new Lock[](1); + commitments[0] = _makeLock(address(usdc), defaultAmount); + + // Deposit ERC20 into Compact so allocation is backed + vm.prank(user); + usdc.approve(address(compact), defaultAmount); + vm.prank(user); + compact.depositERC20(address(usdc), commitments[0].lockTag, defaultAmount, user); + + vm.prank(user); + (bytes32 claimHash, uint256 nonce) = + allocator.allocate(commitments, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, bytes32(0)); + vm.snapshotGasLastCall('allocate_erc20'); + + uint256[2][] memory idsAndAmounts = new uint256[2][](1); + idsAndAmounts[0][0] = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(usdc)); + idsAndAmounts[0][1] = defaultAmount; + + assertEq(nonce, defaultNonce); + assertTrue(allocator.isClaimAuthorized(claimHash, arbiter, user, nonce, defaultExpiration, idsAndAmounts, '')); + } + + function test_allocate_success_erc20_multipleAllocations() public { + uint256 amount = defaultAmount / 2; + Lock[] memory commitments = new Lock[](1); + commitments[0] = _makeLock(address(usdc), amount); + + // Deposit ERC20 into Compact so allocation is backed + vm.prank(user); + usdc.approve(address(compact), defaultAmount); + vm.prank(user); + compact.depositERC20(address(usdc), commitments[0].lockTag, defaultAmount, user); + + vm.prank(user); + (bytes32 claimHash, uint256 nonce) = + allocator.allocate(commitments, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, bytes32(0)); + vm.snapshotGasLastCall('allocate_erc20'); + + uint256[2][] memory idsAndAmounts = new uint256[2][](1); + idsAndAmounts[0][0] = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(usdc)); + idsAndAmounts[0][1] = amount; + + assertEq(nonce, defaultNonce); + assertTrue(allocator.isClaimAuthorized(claimHash, arbiter, user, nonce, defaultExpiration, idsAndAmounts, '')); + + vm.prank(user); + (claimHash, nonce) = + allocator.allocate(commitments, arbiter, defaultExpiration + 10, BATCH_COMPACT_TYPEHASH, bytes32(0)); + vm.snapshotGasLastCall('allocate_second_erc20'); + + assertEq(nonce, defaultNonce + 1); + assertTrue( + allocator.isClaimAuthorized(claimHash, arbiter, user, nonce, defaultExpiration + 10, idsAndAmounts, '') + ); + + // expire the first allocation and allocate again + vm.warp(defaultExpiration + 1); + vm.prank(user); + (claimHash, nonce) = + allocator.allocate(commitments, arbiter, defaultExpiration + 10, BATCH_COMPACT_TYPEHASH, bytes32(0)); + vm.snapshotGasLastCall('allocate_and_delete_expired_allocation'); + + assertEq(nonce, defaultNonce + 2); + assertTrue( + allocator.isClaimAuthorized(claimHash, arbiter, user, nonce, defaultExpiration + 10, idsAndAmounts, '') + ); + } + + function test_allocate_fuzz(uint128 depositAmount, uint128 firstAmount, uint128 secondAmount, bytes32 witness) + public + { + vm.assume(depositAmount > 0); + vm.assume(firstAmount <= depositAmount); + + Lock[] memory commitments = new Lock[](1); + commitments[0] = _makeLock(address(usdc), firstAmount); + + // Deposit ERC20 into Compact so allocation is backed + vm.startPrank(user); + usdc.mint(user, depositAmount); + usdc.approve(address(compact), depositAmount); + compact.depositERC20(address(usdc), commitments[0].lockTag, depositAmount, user); + vm.stopPrank(); + + uint256 expectedNonce = defaultNonce; + bytes32 claimHash = _createClaimHash(user, arbiter, expectedNonce, defaultExpiration, commitments, witness); + + // first allocation + + bytes32 typehash = witness == bytes32(0) ? BATCH_COMPACT_TYPEHASH : BATCH_COMPACT_TYPEHASH_WITH_WITNESS; + + vm.prank(user); + vm.expectEmit(true, true, true, true); + emit IOnChainAllocation.Allocated(user, commitments, expectedNonce, defaultExpiration, claimHash); + (bytes32 returnedClaimHash, uint256 nonce) = + allocator.allocate(commitments, arbiter, defaultExpiration, typehash, witness); + + assertEq(returnedClaimHash, claimHash); + + uint256[2][] memory idsAndAmounts = new uint256[2][](1); + idsAndAmounts[0][0] = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(usdc)); + idsAndAmounts[0][1] = firstAmount; + + assertEq(nonce, expectedNonce, 'nonce 1'); + assertTrue(allocator.isClaimAuthorized(claimHash, arbiter, user, nonce, defaultExpiration, idsAndAmounts, '')); + + // second allocation + commitments[0].amount = secondAmount; + + vm.prank(user); + if (uint256(secondAmount) + uint256(firstAmount) > depositAmount) { + // expect a revert of the second allocation + vm.expectRevert( + abi.encodeWithSelector( + IOnChainAllocator.InsufficientBalance.selector, + user, + _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(usdc)), + depositAmount - firstAmount, + secondAmount + ) + ); + } else { + // expect a successful second allocation + expectedNonce = defaultNonce + 1; + claimHash = _createClaimHash(user, arbiter, expectedNonce, defaultExpiration, commitments, witness); + vm.expectEmit(true, true, true, true); + emit IOnChainAllocation.Allocated(user, commitments, expectedNonce, defaultExpiration, claimHash); + } + (claimHash, nonce) = allocator.allocate(commitments, arbiter, defaultExpiration, typehash, witness); + + if (uint256(secondAmount) + uint256(firstAmount) <= depositAmount) { + // Check the allocations + idsAndAmounts[0][1] = secondAmount; + + assertEq(nonce, expectedNonce, 'nonce 1'); + assertTrue( + allocator.isClaimAuthorized( + claimHash, arbiter, user, defaultNonce, /*nonce*/ defaultExpiration, idsAndAmounts, '' + ) + ); + assertTrue( + allocator.isClaimAuthorized( + claimHash, arbiter, user, defaultNonce + 1, /*nonce*/ defaultExpiration, idsAndAmounts, '' + ) + ); + + uint256 amountToAttest = depositAmount - (uint256(secondAmount) + uint256(firstAmount)); + + assertEq( + allocator.attest(address(this), user, address(this), idsAndAmounts[0][0], amountToAttest), + IAllocator.attest.selector + ); + vm.expectRevert( + abi.encodeWithSelector( + IOnChainAllocator.InsufficientBalance.selector, + user, + idsAndAmounts[0][0], + amountToAttest, + amountToAttest + 1 + ) + ); + allocator.attest(address(this), user, address(this), idsAndAmounts[0][0], amountToAttest + 1); + } else if (secondAmount <= depositAmount) { + // Second allocation should be possible after the first one is expired + vm.warp(defaultExpiration + 1); + uint32 expiration = defaultExpiration + 100; + expectedNonce = defaultNonce + 1; + + vm.prank(user); + (claimHash, nonce) = allocator.allocate(commitments, arbiter, expiration, typehash, witness); + assertEq(nonce, expectedNonce, 'nonce 2'); + assertTrue(allocator.isClaimAuthorized(claimHash, arbiter, user, nonce, expiration, idsAndAmounts, '')); + } + } + + /* --------------------------------------------------------------------- */ + /* allocateFor() */ + /* --------------------------------------------------------------------- */ + function test_allocateFor_revert_InvalidExpiration(address relayer) public { + Lock[] memory commitments = new Lock[](1); + commitments[0] = _makeLock(address(0), defaultAmount); + vm.prank(user); + compact.depositNative{value: defaultAmount}(commitments[0].lockTag, user); + + vm.warp(defaultExpiration + 1); + + vm.prank(relayer); + vm.expectRevert( + abi.encodeWithSelector(IOnChainAllocator.InvalidExpiration.selector, defaultExpiration, block.timestamp) + ); + allocator.allocateFor(user, commitments, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, 0x0, ''); + } + + function test_allocateFor_revert_InvalidSignature() public { + Lock[] memory commitments = new Lock[](1); + commitments[0] = _makeLock(address(0), defaultAmount); + vm.prank(user); + compact.depositNative{value: defaultAmount}(commitments[0].lockTag, user); + + (address attacker, uint256 attackerPK) = makeAddrAndKey('attacker'); + + // build digest exactly like allocator expects + uint256 expectedNonce = _composeNonceUint(user, allocator.nonces(user) + 1); + + bytes32 commitmentsHash = _commitmentsHash(commitments); + bytes32 claimHash = keccak256( + abi.encode(BATCH_COMPACT_TYPEHASH, arbiter, user, expectedNonce, defaultExpiration, commitmentsHash) + ); + bytes32 digest = keccak256(abi.encodePacked(bytes2(0x1901), compact.DOMAIN_SEPARATOR(), claimHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(attackerPK, digest); + bytes memory badSig = abi.encodePacked(r, s, v); + + vm.prank(attacker); + vm.expectRevert(abi.encodeWithSelector(IOnChainAllocator.InvalidSignature.selector, attacker, user)); + allocator.allocateFor(user, commitments, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, 0x0, badSig); + } + + function test_allocateFor_revert_InvalidSignature_invalidSignatureLength(address relayer) public { + Lock[] memory commitments = new Lock[](1); + commitments[0] = _makeLock(address(0), defaultAmount); + vm.prank(user); + compact.depositNative{value: defaultAmount}(commitments[0].lockTag, user); + + // build digest exactly like allocator expects + uint256 expectedNonce = _composeNonceUint(user, allocator.nonces(user) + 1); + + bytes32 commitmentsHash = _commitmentsHash(commitments); + bytes32 claimHash = keccak256( + abi.encode(BATCH_COMPACT_TYPEHASH, arbiter, user, expectedNonce, defaultExpiration, commitmentsHash) + ); + bytes32 digest = keccak256(abi.encodePacked(bytes2(0x1901), compact.DOMAIN_SEPARATOR(), claimHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(userPK, digest); + bytes memory sig = abi.encode(r, s, v); // wrong length because not packed: 96 bytes instead of 65 bytes + + vm.prank(relayer); + vm.expectRevert(abi.encodeWithSelector(IOnChainAllocator.InvalidSignature.selector, address(0), user)); + allocator.allocateFor(user, commitments, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, 0x0, sig); + } + + function test_allocateFor_success_withCompactSignature(address relayer) public { + Lock[] memory commitments = new Lock[](1); + commitments[0] = _makeLock(address(0), defaultAmount); + vm.prank(user); + compact.depositNative{value: defaultAmount}(commitments[0].lockTag, user); + + // build digest exactly like allocator expects + uint256 expectedNonce = _composeNonceUint(user, allocator.nonces(user) + 1); + + bytes32 commitmentsHash = _commitmentsHash(commitments); + bytes32 claimHash = keccak256( + abi.encode(BATCH_COMPACT_TYPEHASH, arbiter, user, expectedNonce, defaultExpiration, commitmentsHash) + ); + bytes32 digest = keccak256(abi.encodePacked(bytes2(0x1901), compact.DOMAIN_SEPARATOR(), claimHash)); + (bytes32 r, bytes32 vs) = vm.signCompact(userPK, digest); + bytes memory sig = abi.encodePacked(r, vs); + + vm.prank(relayer); + (bytes32 returnedHash, uint256 nonce) = + allocator.allocateFor(user, commitments, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, 0x0, sig); + + uint256[2][] memory idsAndAmounts = new uint256[2][](1); + idsAndAmounts[0][0] = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(0)); + idsAndAmounts[0][1] = defaultAmount; + + assertEq(returnedHash, claimHash); + assertEq(nonce, expectedNonce); + assertTrue(allocator.isClaimAuthorized(claimHash, arbiter, user, nonce, defaultExpiration, idsAndAmounts, '')); + } + + function test_allocateFor_success_withSignature(address relayer) public { + Lock[] memory commitments = new Lock[](1); + commitments[0] = _makeLock(address(0), defaultAmount); + vm.prank(user); + compact.depositNative{value: defaultAmount}(commitments[0].lockTag, user); + + // build digest exactly like allocator expects + uint256 expectedNonce = _composeNonceUint(user, allocator.nonces(user) + 1); + bytes32 commitmentsHash = _commitmentsHash(commitments); + bytes32 claimHash = keccak256( + abi.encode(BATCH_COMPACT_TYPEHASH, arbiter, user, expectedNonce, defaultExpiration, commitmentsHash) + ); + bytes32 digest = keccak256(abi.encodePacked(bytes2(0x1901), compact.DOMAIN_SEPARATOR(), claimHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(userPK, digest); + bytes memory sig = abi.encodePacked(r, s, v); + + vm.prank(relayer); + (bytes32 returnedHash, uint256 nonce) = + allocator.allocateFor(user, commitments, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, 0x0, sig); + + uint256[2][] memory idsAndAmounts = new uint256[2][](1); + idsAndAmounts[0][0] = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(0)); + idsAndAmounts[0][1] = defaultAmount; + + assertEq(returnedHash, claimHash); + assertEq(nonce, expectedNonce); + assertTrue(allocator.isClaimAuthorized(claimHash, arbiter, user, nonce, defaultExpiration, idsAndAmounts, '')); + } + + function test_allocateFor_success_withWitness(address relayer) public { + Lock[] memory commitments = new Lock[](1); + commitments[0] = _makeLock(address(0), defaultAmount); + vm.prank(user); + compact.depositNative{value: defaultAmount}(commitments[0].lockTag, user); + + // build digest exactly like allocator expects + uint256 expectedNonce = _composeNonceUint(user, allocator.nonces(user) + 1); + bytes32 witness = bytes32(keccak256('witness')); + bytes32 commitmentsHash = _commitmentsHash(commitments); + bytes32 claimHash = keccak256( + abi.encode( + BATCH_COMPACT_TYPEHASH, arbiter, user, expectedNonce, defaultExpiration, commitmentsHash, witness + ) + ); + bytes memory sig; + { + bytes32 digest = keccak256(abi.encodePacked(bytes2(0x1901), compact.DOMAIN_SEPARATOR(), claimHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(userPK, digest); + sig = abi.encodePacked(r, s, v); + } + vm.prank(relayer); + (bytes32 returnedHash, uint256 nonce) = + allocator.allocateFor(user, commitments, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, witness, sig); + + uint256[2][] memory idsAndAmounts = new uint256[2][](1); + idsAndAmounts[0][0] = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(0)); + idsAndAmounts[0][1] = defaultAmount; + + assertEq(returnedHash, claimHash); + assertEq(nonce, expectedNonce); + assertTrue(allocator.isClaimAuthorized(claimHash, arbiter, user, nonce, defaultExpiration, idsAndAmounts, '')); + + assertEq(allocator.nonces(user), 1); + } + + function test_allocateFor_revert_InvalidRegistration(address relayer) public { + // Build commitments with native token deposit backing + Lock[] memory commitments = new Lock[](1); + commitments[0] = _makeLock(address(0), defaultAmount); + vm.prank(user); + compact.depositNative{value: defaultAmount}(commitments[0].lockTag, user); + + // Nonce that allocateFor will use + uint256 expectedNonce = _composeNonceUint(user, allocator.nonces(user) + 1); + + // Compute claimHash that allocateFor will create internally + bytes32 claimHash = _createClaimHash(user, arbiter, expectedNonce, defaultExpiration, commitments, bytes32(0)); + + // Expect InvalidRegistration revert because claimHash is NOT registered on The Compact + vm.prank(relayer); + vm.expectRevert(abi.encodeWithSelector(IOnChainAllocation.InvalidRegistration.selector, user, claimHash)); + allocator.allocateFor( + user, + commitments, + arbiter, + defaultExpiration, + BATCH_COMPACT_TYPEHASH, + bytes32(0), + '' // empty signature triggers registration check + ); + } + + function test_allocateFor_success_noSignature() public { + address relayer = makeAddr('relayer'); + Lock[] memory commitments = new Lock[](1); + commitments[0] = _makeLock(address(0), defaultAmount); + + // Determine nonce the allocator will use + uint256 expectedNonce = _composeNonceUint(user, allocator.nonces(user) + 1); + + // Pre-compute claimHash that `allocateFor` will produce + bytes32 claimHash = _createClaimHash(user, arbiter, expectedNonce, defaultExpiration, commitments, bytes32(0)); + + // Prepare ids & amounts for native token deposit + registration + uint256[2][] memory idsAndAmounts = new uint256[2][](1); + idsAndAmounts[0][0] = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(0)); + idsAndAmounts[0][1] = defaultAmount; + + // Prepare claimHashes+typehashes array for registration + bytes32[2][] memory claimHashesAndTypehashes = new bytes32[2][](1); + claimHashesAndTypehashes[0][0] = claimHash; + claimHashesAndTypehashes[0][1] = BATCH_COMPACT_TYPEHASH; + + // User deposits native token & registers the compact directly on TheCompact + vm.prank(user); + compact.batchDepositAndRegisterMultiple{value: defaultAmount}(idsAndAmounts, claimHashesAndTypehashes); + + // Relayer submits allocateFor WITHOUT any signature (length == 0) + vm.prank(relayer); + (bytes32 returnedHash, uint256 nonce) = allocator.allocateFor( + user, + commitments, + arbiter, + defaultExpiration, + BATCH_COMPACT_TYPEHASH, + bytes32(0), + '' // empty signature triggers the "registered" code path + ); + vm.snapshotGasLastCall('allocateFor_success_withRegistration'); + + // Assertions + assertEq(returnedHash, claimHash); + assertEq(nonce, expectedNonce); + assertTrue(allocator.isClaimAuthorized(claimHash, arbiter, user, nonce, defaultExpiration, idsAndAmounts, '')); + } + + /* --------------------------------------------------------------------- */ + /* isClaimAuthorized() */ + /* --------------------------------------------------------------------- */ + + function test_isClaimAuthorized_false_notAuthorized() public view { + assertFalse(allocator.isClaimAuthorized(bytes32(0), arbiter, user, 0, 0, new uint256[2][](0), '')); + } + + function test_isClaimAuthorized_false_expired() public { + Lock[] memory commitments = new Lock[](1); + commitments[0] = _makeLock(address(0), defaultAmount); + vm.prank(user); + compact.depositNative{value: defaultAmount}(commitments[0].lockTag, user); + + vm.prank(user); + (bytes32 claimHash, uint256 nonce) = + allocator.allocate(commitments, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, bytes32(0)); + + uint256[2][] memory idsAndAmounts = new uint256[2][](1); + idsAndAmounts[0][0] = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(0)); + idsAndAmounts[0][1] = defaultAmount; + + vm.prank(user); + assertTrue(allocator.isClaimAuthorized(claimHash, arbiter, user, nonce, defaultExpiration, idsAndAmounts, '')); + + vm.warp(defaultExpiration + 1); + vm.prank(user); + assertFalse(allocator.isClaimAuthorized(claimHash, arbiter, user, nonce, defaultExpiration, idsAndAmounts, '')); + } + + function test_isClaimAuthorized_success() public { + Lock[] memory commitments = new Lock[](1); + commitments[0] = _makeLock(address(0), defaultAmount); + vm.prank(user); + compact.depositNative{value: defaultAmount}(commitments[0].lockTag, user); + + vm.prank(user); + (bytes32 claimHash, uint256 nonce) = + allocator.allocate(commitments, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, bytes32(0)); + + uint256[2][] memory idsAndAmounts = new uint256[2][](1); + idsAndAmounts[0][0] = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(0)); + idsAndAmounts[0][1] = defaultAmount; + + vm.prank(user); + assertTrue(allocator.isClaimAuthorized(claimHash, arbiter, user, nonce, defaultExpiration, idsAndAmounts, '')); + + vm.warp(defaultExpiration); + vm.prank(user); + assertTrue(allocator.isClaimAuthorized(claimHash, arbiter, user, nonce, defaultExpiration, idsAndAmounts, '')); + } + + /* --------------------------------------------------------------------- */ + /* authorizeClaim() */ + /* --------------------------------------------------------------------- */ + + function test_authorizeClaim_invalidCaller() public { + vm.expectRevert( + abi.encodeWithSelector(IOnChainAllocator.InvalidCaller.selector, address(this), address(compact)) + ); + allocator.authorizeClaim(bytes32(0), arbiter, user, 0, 0, new uint256[2][](0), ''); + } + + function test_authorizeClaim_success() public { + // register claim via allocate() + Lock[] memory commitments = new Lock[](1); + commitments[0] = _makeLock(address(0), defaultAmount); + + // back with native deposit + bytes12 lt = commitments[0].lockTag; + vm.prank(user); + compact.depositNative{value: defaultAmount}(lt, user); + vm.prank(user); + (bytes32 claimHash,) = + allocator.allocate(commitments, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, bytes32(0)); + + uint256[2][] memory idsAndAmounts = new uint256[2][](1); + uint256 idNat = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(0)); + idsAndAmounts[0][0] = idNat; + idsAndAmounts[0][1] = defaultAmount; + + // call from Compact contract address + vm.prank(address(compact)); + bytes4 sel = allocator.authorizeClaim(claimHash, arbiter, user, 1, defaultExpiration, idsAndAmounts, ''); + assertEq(sel, IAllocator.authorizeClaim.selector); + + // check deletion of the allocation + vm.prank(address(compact)); + vm.expectRevert(abi.encodeWithSelector(IOnChainAllocator.InvalidClaim.selector, claimHash)); + allocator.authorizeClaim(claimHash, arbiter, user, 1, defaultExpiration, idsAndAmounts, ''); + } + + function test_authorizeClaim_deletesMiddleOfMultipleAllocations_correctly() public { + // Prepare a large ERC20 deposit so three allocations can be made for the same id. + uint256 amount1 = 1 ether; + uint256 amount2 = 2 ether; + uint256 amount3 = 3 ether; + uint256 total = amount1 + amount2 + amount3; + + // Deposit ERC20 into Compact for the user + bytes12 lockTag = _toLockTag(address(allocator), Scope.Multichain, ResetPeriod.TenMinutes); + vm.startPrank(user); + usdc.mint(user, total); + usdc.approve(address(compact), total); + compact.depositERC20(address(usdc), lockTag, total, user); + vm.stopPrank(); + + // Make three allocations for the same id with increasing expirations + Lock[] memory commitments = new Lock[](1); + commitments[0] = Lock({lockTag: lockTag, token: address(usdc), amount: amount1}); + bytes32 claimHash1; + { + vm.prank(user); + (claimHash1,) = allocator.allocate(commitments, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, ''); + } + + bytes32 claimHash2; + { + commitments[0].amount = amount2; + vm.prank(user); + (claimHash2,) = allocator.allocate(commitments, arbiter, defaultExpiration + 10, BATCH_COMPACT_TYPEHASH, ''); + } + + bytes32 claimHash3; + { + commitments[0].amount = amount3; + vm.prank(user); + (claimHash3,) = allocator.allocate(commitments, arbiter, defaultExpiration + 20, BATCH_COMPACT_TYPEHASH, ''); + } + + // idsAndAmounts used by authorizeClaim (amount is not used for verification but keep it consistent) + uint256[2][] memory idsAndAmounts = new uint256[2][](1); + idsAndAmounts[0][0] = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(usdc)); + idsAndAmounts[0][1] = amount1; + + // 1) Delete the MIDDLE allocation first (claimHash2). This exercises swap-and-pop correctness. + vm.prank(address(compact)); + bytes4 sel = allocator.authorizeClaim(claimHash2, arbiter, user, 0, defaultExpiration, idsAndAmounts, ''); + assertEq(sel, IAllocator.authorizeClaim.selector); + + // 2) The other allocations must still be present and independently deletable. + vm.prank(address(compact)); + sel = allocator.authorizeClaim(claimHash3, arbiter, user, 0, defaultExpiration, idsAndAmounts, ''); + assertEq(sel, IAllocator.authorizeClaim.selector); + + vm.prank(address(compact)); + sel = allocator.authorizeClaim(claimHash1, arbiter, user, 0, defaultExpiration, idsAndAmounts, ''); + assertEq(sel, IAllocator.authorizeClaim.selector); + + // 3) All allocations are deleted now; reusing any claim should revert. + vm.prank(address(compact)); + vm.expectRevert(abi.encodeWithSelector(IOnChainAllocator.InvalidClaim.selector, claimHash1)); + allocator.authorizeClaim(claimHash1, arbiter, user, 0, defaultExpiration, idsAndAmounts, ''); + } + + /* --------------------------------------------------------------------- */ + /* attest */ + /* --------------------------------------------------------------------- */ + + function test_attest_revert_InsufficientBalance() public { + uint256 id = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(0)); + vm.prank(user); + vm.expectRevert( + abi.encodeWithSelector(IOnChainAllocator.InsufficientBalance.selector, user, id, 0, defaultAmount) + ); + allocator.attest(address(0), user, address(0), id, defaultAmount); + } + + function test_attest_revert_InsufficientBalance_previousAllocation() public { + Lock[] memory commitments = new Lock[](1); + commitments[0] = _makeLock(address(0), defaultAmount); + vm.prank(user); + compact.depositNative{value: defaultAmount}(commitments[0].lockTag, user); + + vm.prank(user); + allocator.allocate(commitments, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, bytes32(0)); + + uint256 id = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(0)); + vm.prank(user); + vm.expectRevert(abi.encodeWithSelector(IOnChainAllocator.InsufficientBalance.selector, user, id, 0, 1)); + allocator.attest(address(this), user, address(this), id, 1); + } + + function test_attest_success_previousAllocation() public { + Lock[] memory commitments = new Lock[](1); + commitments[0] = _makeLock(address(0), defaultAmount - 1); + vm.prank(user); + compact.depositNative{value: defaultAmount}(commitments[0].lockTag, user); + + vm.prank(user); + allocator.allocate(commitments, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, bytes32(0)); + + uint256 id = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(0)); + vm.prank(user); + vm.assertEq(allocator.attest(address(this), user, address(this), id, 1), allocator.attest.selector); + } + + function test_attest_success() public { + uint256 id = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(0)); + // deposit id to Compact for user + vm.prank(user); + compact.depositNative{value: defaultAmount}(bytes12(bytes32(id)), user); + + vm.prank(user); + bytes4 sel = allocator.attest(address(0), user, address(0), id, defaultAmount); + assertEq(sel, allocator.attest.selector); + } + + /* --------------------------------------------------------------------- */ + /* allocateAndRegister() */ + /* --------------------------------------------------------------------- */ + + function test_allocateAndRegister_revert_InvalidExpiration() public { + Lock[] memory commitments = new Lock[](1); + commitments[0] = _makeLock(address(usdc), defaultAmount); + + // Fund allocator with tokens + usdc.mint(address(allocator), defaultAmount); + + uint256 expiration = block.timestamp + 600; + vm.prank(caller); + vm.expectRevert( + abi.encodeWithSelector(IOnChainAllocator.InvalidExpiration.selector, expiration, block.timestamp + 600) + ); + allocator.allocateAndRegister( + recipient, commitments, arbiter, uint32(expiration), BATCH_COMPACT_TYPEHASH, bytes32(0) + ); + } + + /* --------------------------------------------------------------------- */ + /* prepareAllocation / executeAllocation */ + /* --------------------------------------------------------------------- */ + + function _idsAndAmountsFor(address token, uint256 amount) + internal + view + returns (uint256[2][] memory idsAndAmounts) + { + idsAndAmounts = new uint256[2][](1); + idsAndAmounts[0][0] = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), token); + idsAndAmounts[0][1] = amount; + } + + function _idsAndAmountsFor2(address tokenA, uint256 amountA, address tokenB, uint256 amountB) + internal + view + returns (uint256[2][] memory idsAndAmounts) + { + idsAndAmounts = new uint256[2][](2); + idsAndAmounts[0][0] = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), tokenA); + idsAndAmounts[0][1] = amountA; + idsAndAmounts[1][0] = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), tokenB); + idsAndAmounts[1][1] = amountB; + } + + function test_prepareAllocation_returnsNonce_and_doesNotIncrementStorage() public { + uint256[2][] memory idsAndAmounts = _idsAndAmountsFor(address(usdc), defaultAmount); + + // call from an arbitrary EOA (caller) + vm.prank(caller); + uint256 returnedNonce = allocator.prepareAllocation( + recipient, idsAndAmounts, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, bytes32(0), '' + ); + + assertEq(returnedNonce, _composeNonceUint(caller, 1)); + // storage nonce is only incremented in executeAllocation + assertEq(allocator.nonces(caller), 0); + } + + function test_executeAllocation_success_viaCaller_singleERC20() public { + uint256 amount = defaultAmount; + uint256[2][] memory idsAndAmounts = _idsAndAmountsFor(address(usdc), amount); + + // fund and approve from allocationCaller + usdc.mint(address(allocationCaller), amount); + vm.prank(address(allocationCaller)); + usdc.approve(address(compact), amount); + + // Check nonce previous to the allocation + assertEq(allocator.nonces(address(allocationCaller)), 0); + + // run the whole flow in a single tx through the helper + allocationCaller.onChainAllocation( + recipient, idsAndAmounts, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, bytes32(0), 0 + ); + vm.snapshotGasLastCall('onchain_execute_single'); + + // nonce is scoped to (callerContract, recipient) + assertEq(allocator.nonces(address(allocationCaller)), 1); + uint256 expectedNonce = _composeNonceUint(address(allocationCaller), 1); + + // compute claim hash and check authorization + Lock[] memory commitments = _idsAndAmountsToCommitments(idsAndAmounts); + bytes32 claimHash = + _createClaimHash(recipient, arbiter, expectedNonce, defaultExpiration, commitments, bytes32(0)); + + assertTrue( + allocator.isClaimAuthorized( + claimHash, arbiter, recipient, expectedNonce, defaultExpiration, idsAndAmounts, '' + ) + ); + } + + function test_executeAllocation_revert_InvalidPreparation() public { + uint256 amount = defaultAmount; + uint256[2][] memory idsAndAmounts = _idsAndAmountsFor(address(usdc), amount); + + // fund and approve from allocationCaller + usdc.mint(address(allocationCaller), amount); + vm.prank(address(allocationCaller)); + usdc.approve(address(compact), amount); + + // todo = 2: deposit+register without prepareAllocation -> executeAllocation must revert InvalidPreparation + vm.prank(user); + vm.expectRevert(AllocatorLib.InvalidPreparation.selector); + allocationCaller.onChainAllocation( + recipient, idsAndAmounts, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, bytes32(0), 2 + ); + } + + function test_executeAllocation_revert_InvalidRegistration() public { + uint256 amount = defaultAmount; + uint256[2][] memory idsAndAmounts = _idsAndAmountsFor(address(usdc), amount); + + // fund and approve from allocationCaller + usdc.mint(address(allocationCaller), amount); + vm.prank(address(allocationCaller)); + usdc.approve(address(compact), amount); + + // todo = 1: deposit only (no registration) -> executeAllocation must revert InvalidRegistration + // Expect the precise error and arguments from AllocatorLib + // Compute the claimHash that AllocatorLib will recompute during execute. + Lock[] memory commitments = _idsAndAmountsToCommitments(idsAndAmounts); + bytes32 expectedClaimHash = _createClaimHash( + recipient, + arbiter, + _composeNonceUint(address(allocationCaller), 1), + defaultExpiration, + commitments, + bytes32(0) + ); + vm.prank(user); + vm.expectRevert( + abi.encodeWithSelector( + AllocatorLib.InvalidRegistration.selector, recipient, expectedClaimHash, BATCH_COMPACT_TYPEHASH + ) + ); + allocationCaller.onChainAllocation( + recipient, idsAndAmounts, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, bytes32(0), 1 + ); + } + + function test_executeAllocation_revert_InvalidBalanceChange_onZeroAmountSecondId() public { + uint256 amountA = defaultAmount; + uint256 amountB = 0; // no deposit for second id -> balance unchanged -> InvalidBalanceChange + uint256[2][] memory idsAndAmounts = _idsAndAmountsFor2(address(usdc), amountA, address(dai), amountB); + + // fund and approve only the first token + usdc.mint(address(allocationCaller), amountA); + vm.startPrank(address(allocationCaller)); + usdc.approve(address(compact), amountA); + // approve DAI even if amount is zero to avoid allowance issues + dai.approve(address(compact), 0); + vm.stopPrank(); + + // Even though registration will succeed (with 0 for the second id), executeAllocation should revert + vm.prank(user); + // Revert happens inside TheCompact deposit logic before executeAllocation runs + // Use the selector for InvalidDepositBalanceChange() + vm.expectRevert(bytes4(keccak256('InvalidDepositBalanceChange()'))); + allocationCaller.onChainAllocation( + recipient, idsAndAmounts, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, bytes32(0), 0 + ); + } + + function test_executeAllocation_success_twoIds() public { + uint256 amountA = defaultAmount; + uint256 amountB = defaultAmount / 2; + uint256[2][] memory idsAndAmounts = _idsAndAmountsFor2(address(usdc), amountA, address(dai), amountB); + + // fund & approve caller for both tokens + usdc.mint(address(allocationCaller), amountA); + dai.mint(address(allocationCaller), amountB); + vm.startPrank(address(allocationCaller)); + usdc.approve(address(compact), amountA); + dai.approve(address(compact), amountB); + vm.stopPrank(); + + vm.prank(user); + allocationCaller.onChainAllocation( + recipient, idsAndAmounts, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, bytes32(0), 0 + ); + vm.snapshotGasLastCall('onchain_execute_double'); + + // authorization with the measured amounts + uint256 expectedNonce = _composeNonceUint(address(allocationCaller), 1); + + assertTrue( + allocator.isClaimAuthorized( + _createClaimHash( + recipient, + arbiter, + expectedNonce, + defaultExpiration, + _idsAndAmountsToCommitments(idsAndAmounts), + bytes32(0) + ), + arbiter, + recipient, + expectedNonce, + defaultExpiration, + idsAndAmounts, + '' + ) + ); + } + + function test_executeAllocation_revert_InvalidBalanceChange_noDeposit() public { + // Prepare only, no deposit → newBalance <= oldBalance → InvalidBalanceChange + uint256 amount = defaultAmount; + uint256[2][] memory idsAndAmounts = _idsAndAmountsFor(address(usdc), amount); + + // Give recipient a prior ERC6909 balance so revert is not (0,0) + bytes12 lockTag = _toLockTag(address(allocator), Scope.Multichain, ResetPeriod.TenMinutes); + vm.startPrank(user); + usdc.mint(user, amount); + usdc.approve(address(compact), amount); + compact.depositERC20(address(usdc), lockTag, amount, recipient); + vm.stopPrank(); + + vm.prank(user); + vm.expectRevert(abi.encodeWithSignature('InvalidBalanceChange(uint256,uint256)', amount, amount)); + allocationCaller.onChainAllocation( + recipient, idsAndAmounts, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, bytes32(0), 3 + ); + } + + function test_executeAllocation_revert_InvalidPreparation_replaySameTx() public { + // First execute succeeds; second execute in same tx (without new prepare) must fail with InvalidPreparation + uint256 amount = defaultAmount; + uint256[2][] memory idsAndAmounts = _idsAndAmountsFor(address(usdc), amount); + + // fund and approve caller for deposit + usdc.mint(address(allocationCaller), amount); + vm.prank(address(allocationCaller)); + usdc.approve(address(compact), amount); + + vm.prank(user); + vm.expectRevert(AllocatorLib.InvalidPreparation.selector); + // todo=4 triggers deposit+register + execute, then a second execute at function end + allocationCaller.onChainAllocation( + recipient, idsAndAmounts, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, bytes32(0), 4 + ); + } + + function test_executeAllocation_fullAllocation_preventsFurtherAllocate() public { + uint256 amount = defaultAmount; + uint256[2][] memory idsAndAmounts = _idsAndAmountsFor(address(usdc), amount); + + // fund and approve caller for deposit + usdc.mint(address(allocationCaller), amount); + vm.prank(address(allocationCaller)); + usdc.approve(address(compact), amount); + + // perform correct prepare + deposit + register + execute + vm.prank(user); + allocationCaller.onChainAllocation( + recipient, idsAndAmounts, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, bytes32(0), 0 + ); + + // Now the whole balance is allocated for recipient; another allocate should fail + Lock[] memory commitments = new Lock[](1); + commitments[0] = _makeLock(address(usdc), 1); + + uint256 id = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(usdc)); + + vm.prank(recipient); + vm.expectRevert(abi.encodeWithSelector(IOnChainAllocator.InsufficientBalance.selector, recipient, id, 0, 1)); + allocator.allocate(commitments, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, bytes32(0)); + } + + function test_executeAllocation_revert_InvalidAmount_largeDeposit() public { + // Deposit an amount > uint224.max so executeAllocation reverts on range check + uint256 largeAmount = uint256(type(uint224).max) + 1; + uint256[2][] memory idsAndAmounts = _idsAndAmountsFor(address(usdc), largeAmount); + + // fund and approve caller for large amount + usdc.mint(address(allocationCaller), largeAmount); + vm.prank(address(allocationCaller)); + usdc.approve(address(compact), largeAmount); + + vm.prank(user); + vm.expectRevert(abi.encodeWithSelector(IOnChainAllocator.InvalidAmount.selector, largeAmount)); + allocationCaller.onChainAllocation( + recipient, idsAndAmounts, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, bytes32(0), 0 + ); + } + + function test_allocateAndRegister_revert_InvalidAmount() public { + Lock[] memory commitments = new Lock[](1); + commitments[0] = _makeLock(address(usdc), uint256(type(uint224).max) + 1); + + vm.prank(caller); + vm.expectRevert(abi.encodeWithSelector(IOnChainAllocator.InvalidAmount.selector, commitments[0].amount)); + allocator.allocateAndRegister( + recipient, commitments, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, bytes32(0) + ); + } + + function test_allocateAndRegister_revert_InvalidAllocator() public { + Lock[] memory commitments = new Lock[](1); + commitments[0] = _makeLock(address(usdc), defaultAmount); + commitments[0].lockTag = bytes12(commitments[0].lockTag & bytes12(0x110000000000000000000000)); + + vm.prank(caller); + vm.expectRevert( + abi.encodeWithSelector(IOnChainAllocator.InvalidAllocator.selector, 0, allocator.ALLOCATOR_ID()) + ); + allocator.allocateAndRegister( + recipient, commitments, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, bytes32(0) + ); + } + + function test_allocateAndRegister_success_singleERC20() public { + Lock[] memory commitments = new Lock[](1); + commitments[0] = _makeLock(address(usdc), defaultAmount); + + usdc.mint(address(allocator), defaultAmount); + + vm.prank(caller); + (bytes32 claimHash, uint256[] memory registeredAmounts, uint256 nonce) = allocator.allocateAndRegister( + recipient, commitments, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, bytes32(0) + ); + + uint256[2][] memory idsAndAmounts = new uint256[2][](1); + idsAndAmounts[0][0] = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(usdc)); + idsAndAmounts[0][1] = defaultAmount; + + assertEq(nonce, _composeNonceUint(caller, 1)); + assertEq(registeredAmounts.length, 1); + assertEq(registeredAmounts[0], defaultAmount); + assertEq(ERC6909(address(compact)).balanceOf(recipient, idsAndAmounts[0][0]), defaultAmount); + assertTrue( + allocator.isClaimAuthorized(claimHash, arbiter, recipient, nonce, defaultExpiration, idsAndAmounts, '') + ); + assertTrue(compact.isRegistered(recipient, claimHash, BATCH_COMPACT_TYPEHASH)); + bytes32 claimHashRecreated = + _createClaimHash(recipient, arbiter, nonce, defaultExpiration, commitments, bytes32(0)); + assertEq(claimHashRecreated, claimHash); + } + + function test_allocateAndRegister_success_singleERC20_withWitness(bytes32 witness) public { + Lock[] memory commitments = new Lock[](1); + commitments[0] = _makeLock(address(usdc), defaultAmount); + + bytes32 typehash = witness == bytes32(0) ? BATCH_COMPACT_TYPEHASH : BATCH_COMPACT_TYPEHASH_WITH_WITNESS; + + usdc.mint(address(allocator), defaultAmount); + + vm.prank(caller); + (bytes32 claimHash, uint256[] memory registeredAmounts, uint256 nonce) = + allocator.allocateAndRegister(recipient, commitments, arbiter, defaultExpiration, typehash, witness); + + uint256[2][] memory idsAndAmounts = new uint256[2][](1); + idsAndAmounts[0][0] = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(usdc)); + idsAndAmounts[0][1] = defaultAmount; + + assertEq(nonce, _composeNonceUint(caller, 1)); + assertEq(registeredAmounts.length, 1); + assertEq(registeredAmounts[0], defaultAmount); + assertEq(ERC6909(address(compact)).balanceOf(recipient, idsAndAmounts[0][0]), defaultAmount); + assertTrue( + allocator.isClaimAuthorized(claimHash, arbiter, recipient, nonce, defaultExpiration, idsAndAmounts, '') + ); + assertTrue(compact.isRegistered(recipient, claimHash, typehash)); + bytes32 claimHashRecreated = + _createClaimHash(recipient, arbiter, nonce, defaultExpiration, commitments, witness); + assertEq(claimHashRecreated, claimHash); + } + + function test_allocateAndRegister_success_amountZeroDepositsFullBalance(bytes32 witness) public { + uint256 depositAmount = 5 ether; + usdc.mint(address(allocator), depositAmount); + + Lock[] memory commitments = new Lock[](1); + commitments[0] = _makeLock(address(usdc), 0); + + vm.prank(caller); + (bytes32 claimHash, uint256[] memory registeredAmounts, uint256 nonce) = allocator.allocateAndRegister( + recipient, commitments, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, witness + ); + + uint256 id = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(usdc)); + + assertEq(registeredAmounts[0], depositAmount); + assertEq(usdc.balanceOf(address(allocator)), 0); + assertEq(ERC6909(address(compact)).balanceOf(recipient, id), depositAmount); + + uint256[2][] memory idsAndAmounts = new uint256[2][](1); + idsAndAmounts[0][0] = id; + idsAndAmounts[0][1] = depositAmount; + + assertTrue( + allocator.isClaimAuthorized(claimHash, arbiter, recipient, nonce, defaultExpiration, idsAndAmounts, '') + ); + } + + function test_allocateAndRegister_success_multipleERC20() public { + uint256 amount1 = 1 ether; + uint256 amount2 = 2 ether; + + usdc.mint(address(allocator), amount1); + dai.mint(address(allocator), amount2); + + Lock[] memory commitments = new Lock[](2); + commitments[0] = _makeLock(address(usdc), amount1); + commitments[1] = _makeLock(address(dai), amount2); + + vm.prank(caller); + (bytes32 claimHash, uint256[] memory registeredAmounts, uint256 nonce) = allocator.allocateAndRegister( + recipient, commitments, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, bytes32(0) + ); + + uint256 id1 = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(usdc)); + uint256 id2 = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(dai)); + + assertEq(registeredAmounts.length, 2); + assertEq(registeredAmounts[0], amount1); + assertEq(registeredAmounts[1], amount2); + + assertEq(ERC6909(address(compact)).balanceOf(recipient, id1), amount1); + assertEq(ERC6909(address(compact)).balanceOf(recipient, id2), amount2); + + uint256[2][] memory idsAndAmounts = new uint256[2][](2); + idsAndAmounts[0][0] = id1; + idsAndAmounts[0][1] = amount1; + idsAndAmounts[1][0] = id2; + idsAndAmounts[1][1] = amount2; + + assertTrue( + allocator.isClaimAuthorized(claimHash, arbiter, recipient, nonce, defaultExpiration, idsAndAmounts, '') + ); + } + + function test_constructor_allowsPreRegisteredAllocator_create2() public { + OnChainAllocatorFactory factory = new OnChainAllocatorFactory(); + + bytes32 salt = keccak256('onchain-allocator-pre-registered'); + bytes memory initCode = abi.encodePacked(type(OnChainAllocator).creationCode, abi.encode(address(compact))); + bytes32 initCodeHash = keccak256(initCode); + + address expected = + address(uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), address(factory), salt, initCodeHash))))); + + bytes memory proof = abi.encodePacked(bytes1(0xff), address(factory), salt, initCodeHash); + + uint96 preId = compact.__registerAllocator(expected, proof); + assertEq(_toAllocatorId(expected), preId); + + address deployed = OnChainAllocatorFactory(address(factory)).deploy(salt, address(compact)); + assertEq(deployed, expected); + + OnChainAllocator newAllocator = OnChainAllocator(deployed); + assertEq(newAllocator.ALLOCATOR_ID(), _toAllocatorId(deployed)); + } + + function test_allocateAndRegister_tokensImmediatelyAllocated() public { + uint256 amount1 = 1 ether; + uint256 amount2 = 2 ether; + + usdc.mint(address(allocator), amount1); + dai.mint(address(allocator), amount2); + + Lock[] memory commitments = new Lock[](2); + commitments[0] = _makeLock(address(usdc), amount1); + commitments[1] = _makeLock(address(dai), amount2); + + vm.prank(caller); + allocator.allocateAndRegister( + recipient, commitments, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, bytes32(0) + ); + + uint256 id1 = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(usdc)); + uint256 id2 = _toId(Scope.Multichain, ResetPeriod.TenMinutes, address(allocator), address(dai)); + + assertEq(ERC6909(address(compact)).balanceOf(recipient, id1), amount1); + assertEq(ERC6909(address(compact)).balanceOf(recipient, id2), amount2); + + // Try to send a single unit of the tokens + + vm.expectRevert(abi.encodeWithSelector(IOnChainAllocator.InsufficientBalance.selector, recipient, id1, 0, 1)); + allocator.attest(address(this), recipient, address(this), id1, 1); + + vm.expectRevert(abi.encodeWithSelector(IOnChainAllocator.InsufficientBalance.selector, recipient, id2, 0, 1)); + allocator.attest(address(this), recipient, address(this), id2, 1); + } +} diff --git a/test/util/ERC7683TestHelper.sol b/test/util/ERC7683TestHelper.sol new file mode 100644 index 0000000..cefdf9b --- /dev/null +++ b/test/util/ERC7683TestHelper.sol @@ -0,0 +1,457 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import {IERC1271} from '@openzeppelin/contracts/interfaces/IERC1271.sol'; +import {ERC6909} from '@solady/tokens/ERC6909.sol'; +import {TheCompact} from '@uniswap/the-compact/TheCompact.sol'; +import {ITheCompact} from '@uniswap/the-compact/interfaces/ITheCompact.sol'; + +import {IdLib} from '@uniswap/the-compact/lib/IdLib.sol'; + +import {BatchClaim} from '@uniswap/the-compact/types/BatchClaims.sol'; +import {BatchClaimComponent, Component} from '@uniswap/the-compact/types/Components.sol'; +import {BatchCompact, COMPACT_TYPEHASH, LOCK_TYPEHASH, Lock} from '@uniswap/the-compact/types/EIP712Types.sol'; +import {ForcedWithdrawalStatus} from '@uniswap/the-compact/types/ForcedWithdrawalStatus.sol'; +import {ResetPeriod} from '@uniswap/the-compact/types/ResetPeriod.sol'; +import {Scope} from '@uniswap/the-compact/types/Scope.sol'; +import {Test} from 'forge-std/Test.sol'; + +import {TestHelper} from 'test/util/TestHelper.sol'; + +import {IOnChainAllocation} from '@uniswap/the-compact/interfaces/IOnChainAllocation.sol'; + +import {Tribunal} from '@uniswap/tribunal/Tribunal.sol'; +import {Fill, Mandate, RecipientCallback} from '@uniswap/tribunal/types/TribunalStructs.sol'; +import { + COMPACT_TYPEHASH_WITH_MANDATE, + MANDATE_BATCH_COMPACT_TYPEHASH, + MANDATE_FILL_TYPEHASH, + MANDATE_LOCK_TYPEHASH, + MANDATE_RECIPIENT_CALLBACK_TYPEHASH, + MANDATE_TYPEHASH +} from '@uniswap/tribunal/types/TribunalTypeHashes.sol'; + +import {ERC7683Allocator} from 'src/allocators/ERC7683Allocator.sol'; +import {ERC7683AllocatorLib as ERC7683AL} from 'src/allocators/lib/ERC7683AllocatorLib.sol'; +import {IOriginSettler} from 'src/interfaces/ERC7683/IOriginSettler.sol'; +import {IERC7683Allocator} from 'src/interfaces/IERC7683Allocator.sol'; +import {IOnChainAllocator} from 'src/interfaces/IOnChainAllocator.sol'; +import {ERC20Mock} from 'src/test/ERC20Mock.sol'; + +abstract contract MocksSetup is Test, TestHelper { + address user = makeAddr('user'); + uint256 userPK; + address attacker; + uint256 attackerPK; + address arbiter; + address tribunal; + address adjuster; + ERC20Mock usdc; + TheCompact compactContract; + address allocator; + bytes12 usdcLockTag; + uint256 usdcId; + + ResetPeriod defaultResetPeriod = ResetPeriod.OneMinute; + Scope defaultScope = Scope.Multichain; + uint256 defaultResetPeriodTimestamp = 60 - 1; + uint256 defaultAmount = 1000; + uint256 defaultNonce; + uint256 defaultOutputChainId = 130; + address defaultOutputToken = makeAddr('outputToken'); + uint256 defaultMinimumAmount = 1000; + uint256 defaultBaselinePriorityFee = 0; + uint256 defaultScalingFactor = 1e18; + uint256[] defaultPriceCurve = new uint256[](0); + bytes32 defaultSalt = bytes32(0x0000000000000000000000000000000000000000000000000000000000000007); + + uint256[2][] defaultIdsAndAmounts = new uint256[2][](1); + Lock[] defaultCommitments; + + bytes32 ORDERDATA_GASLESS_TYPEHASH; + bytes32 ORDERDATA_ONCHAIN_TYPEHASH; + + uint256 NONCES_STORAGE_SLOT = 1; + + function setUp() public virtual { + (user, userPK) = makeAddrAndKey('user'); + arbiter = makeAddr('arbiter'); + tribunal = makeAddr('tribunal'); + adjuster = makeAddr('adjuster'); + usdc = new ERC20Mock('USDC', 'USDC'); + vm.startPrank(user); + usdc.mint(user, defaultAmount); + usdc.approve(address(compactContract), defaultAmount); + vm.stopPrank(); + + usdcLockTag = _toLockTag(allocator, defaultScope, defaultResetPeriod); + usdcId = _toId(defaultScope, defaultResetPeriod, allocator, address(usdc)); + (attacker, attackerPK) = makeAddrAndKey('attacker'); + + ORDERDATA_GASLESS_TYPEHASH = ERC7683AL.ORDERDATA_GASLESS_TYPEHASH; + ORDERDATA_ONCHAIN_TYPEHASH = ERC7683AL.ORDERDATA_ONCHAIN_TYPEHASH; + } + + function _setUp(address allocator_, TheCompact compactContract_, uint256 defaultNonce_) internal { + allocator = allocator_; + compactContract = compactContract_; + defaultNonce = defaultNonce_; + } + + function _composeNonceUint(address a, uint256 nonce) internal pure returns (uint256) { + return (uint256(uint160(a)) << 96) | nonce; + } + + function _composeNonce(address a, uint256 nonce) internal pure returns (bytes32) { + return bytes32(_composeNonceUint(a, nonce)); + } +} + +abstract contract CreateHash is MocksSetup { + // stringified types + string EIP712_DOMAIN_TYPE = 'EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)'; // Hashed inside the function + // EIP712 domain type + string name = 'The Compact'; + string version = '1'; + bytes32 internal constant EMPTY_HASH = 0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470; + + function _hashCommitments(Lock[] memory commitments) internal pure returns (bytes32) { + bytes32[] memory hashes = new bytes32[](commitments.length); + for (uint256 i = 0; i < commitments.length; i++) { + hashes[i] = keccak256( + abi.encode(LOCK_TYPEHASH, commitments[i].lockTag, commitments[i].token, commitments[i].amount) + ); + } + return keccak256(abi.encodePacked(hashes)); + } + + function _hashRecipientCallback(RecipientCallback[] memory rc) internal pure returns (bytes32) { + if (rc.length == 0) { + return EMPTY_HASH; + } else if (rc.length != 1) { + revert('RecipientCallback not supported in tests'); + } else { + RecipientCallback memory rc_ = rc[0]; + bytes32[] memory commitmentsHashes = new bytes32[](rc_.compact.commitments.length); + for (uint256 i = 0; i < rc_.compact.commitments.length; i++) { + commitmentsHashes[i] = keccak256( + abi.encode( + MANDATE_LOCK_TYPEHASH, + rc_.compact.commitments[i].lockTag, + rc_.compact.commitments[i].token, + rc_.compact.commitments[i].amount + ) + ); + } + bytes32 commitmentsHash = keccak256(abi.encodePacked(commitmentsHashes)); + + return keccak256( + abi.encode( + MANDATE_RECIPIENT_CALLBACK_TYPEHASH, + rc_.compact.arbiter, + rc_.compact.sponsor, + rc_.compact.nonce, + rc_.compact.expires, + commitmentsHash, + rc_.mandateHash + ) + ); + } + } + + function _hashFill(Fill memory f) internal pure returns (bytes32) { + return keccak256( + abi.encode( + MANDATE_FILL_TYPEHASH, + f.chainId, + f.tribunal, + f.expires, + f.fillToken, + f.minimumFillAmount, + f.baselinePriorityFee, + f.scalingFactor, + keccak256(abi.encodePacked(f.priceCurve)), + f.recipient, + _hashRecipientCallback(f.recipientCallback), + f.salt + ) + ); + } + + function _hashMandate(Mandate memory m) internal pure returns (bytes32 mandateHash, bytes32[] memory fillHashes) { + fillHashes = new bytes32[](m.fills.length); + for (uint256 i = 0; i < m.fills.length; i++) { + fillHashes[i] = _hashFill(m.fills[i]); + } + mandateHash = keccak256(abi.encode(MANDATE_TYPEHASH, m.adjuster, keccak256(abi.encodePacked(fillHashes)))); + } + + function _deriveClaimHash(BatchCompact memory compact, Mandate memory mandate) internal pure returns (bytes32) { + (bytes32 mandateHash,) = _hashMandate(mandate); + return _deriveClaimHash(compact, mandateHash); + } + + function _deriveClaimHash(BatchCompact memory compact, bytes32 mandateHash) internal pure returns (bytes32) { + bytes32 commitmentsHash = _staticHashCommitments(compact.commitments); + return keccak256( + abi.encode( + COMPACT_TYPEHASH_WITH_MANDATE, + compact.arbiter, + compact.sponsor, + compact.nonce, + compact.expires, + commitmentsHash, + mandateHash + ) + ); + } + + function _staticHashCommitments(Lock[] memory commitments) private pure returns (bytes32) { + bytes32[] memory hashes = new bytes32[](commitments.length); + for (uint256 i = 0; i < commitments.length; i++) { + hashes[i] = keccak256( + abi.encode(LOCK_TYPEHASH, commitments[i].lockTag, commitments[i].token, commitments[i].amount) + ); + } + return keccak256(abi.encodePacked(hashes)); + } + + function _buildFillHashes(Mandate memory m) internal pure returns (bytes32[] memory hashes) { + hashes = new bytes32[](m.fills.length); + for (uint256 i = 0; i < m.fills.length; i++) { + hashes[i] = _hashFill(m.fills[i]); + } + } + + function _createDigest(BatchCompact memory data, Mandate memory mandate, address verifyingContract) + internal + view + returns (bytes32 digest) + { + (bytes32 mandateHash,) = _hashMandate(mandate); + bytes32 compactHash = _deriveClaimHash(data, mandateHash); + // hash typed data + digest = keccak256( + abi.encodePacked( + '\x19\x01', // backslash is needed to escape the character + _domainSeparator(verifyingContract), + compactHash + ) + ); + } + + function _domainSeparator(address verifyingContract) internal view returns (bytes32) { + return keccak256( + abi.encode( + keccak256(bytes(EIP712_DOMAIN_TYPE)), + keccak256(bytes(name)), + keccak256(bytes(version)), + block.chainid, + verifyingContract + ) + ); + } + + function _signMessage(bytes32 hash_, uint256 signerPK_) internal pure returns (bytes memory) { + (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPK_, hash_); + return abi.encodePacked(r, s, v); + } + + function _hashAndSign(BatchCompact memory data, Mandate memory mandate, address verifyingContract, uint256 signerPK) + internal + view + returns (bytes memory) + { + bytes32 hash = _createDigest(data, mandate, verifyingContract); + bytes memory signature = _signMessage(hash, signerPK); + return signature; + } +} + +abstract contract CompactData is CreateHash { + BatchCompact private compact; + Mandate private mandate; + + function setUp() public virtual override { + super.setUp(); + + defaultIdsAndAmounts[0][0] = usdcId; + defaultIdsAndAmounts[0][1] = defaultAmount; + + defaultCommitments.push(Lock({lockTag: usdcLockTag, token: address(usdc), amount: defaultAmount})); + + compact.arbiter = arbiter; + compact.sponsor = user; + compact.nonce = defaultNonce; + compact.expires = _getClaimExpiration(); + compact.commitments = defaultCommitments; + + Fill memory mainFill; + mainFill.chainId = defaultOutputChainId; + mainFill.tribunal = tribunal; + mainFill.expires = _getFillExpiration(); + mainFill.fillToken = defaultOutputToken; + mainFill.minimumFillAmount = defaultMinimumAmount; + mainFill.baselinePriorityFee = defaultBaselinePriorityFee; + mainFill.scalingFactor = defaultScalingFactor; + mainFill.priceCurve = defaultPriceCurve; + mainFill.recipient = user; + mainFill.salt = defaultSalt; + + mandate.adjuster = adjuster; + mandate.fills = new Fill[](1); + mandate.fills[0] = mainFill; + } + + function _getCompact() internal returns (BatchCompact memory) { + compact.expires = _getClaimExpiration(); + return compact; + } + + function _getMandate() internal returns (Mandate memory) { + mandate.fills[0].expires = _getFillExpiration(); + return mandate; + } + + function _getFillExpiration() internal view returns (uint256) { + return vm.getBlockTimestamp() + defaultResetPeriodTimestamp - 1; + } + + function _getClaimExpiration() internal view returns (uint256) { + return vm.getBlockTimestamp() + defaultResetPeriodTimestamp; + } +} + +abstract contract GaslessCrossChainOrderData is CompactData { + IOriginSettler.GaslessCrossChainOrder private gaslessCrossChainOrder; + + function setUp() public virtual override { + super.setUp(); + + BatchCompact memory compact_ = _getCompact(); + Mandate memory mandate_ = _getMandate(); + + gaslessCrossChainOrder.originSettler = allocator; + gaslessCrossChainOrder.user = compact_.sponsor; + gaslessCrossChainOrder.nonce = _composeNonceUint(compact_.sponsor, defaultNonce); + gaslessCrossChainOrder.originChainId = block.chainid; + gaslessCrossChainOrder.openDeadline = uint32(_getClaimExpiration()); + gaslessCrossChainOrder.fillDeadline = uint32(_getFillExpiration()); + gaslessCrossChainOrder.orderDataType = ORDERDATA_GASLESS_TYPEHASH; + gaslessCrossChainOrder.orderData = abi.encode( + IERC7683Allocator.OrderDataGasless({ + order: IERC7683Allocator.Order({ + arbiter: compact_.arbiter, + commitments: compact_.commitments, + mandate: mandate_ + }), + deposit: false + }) + ); + } + + function _getGaslessCrossChainOrder() internal view returns (IOriginSettler.GaslessCrossChainOrder memory) { + return gaslessCrossChainOrder; + } + + function _getGaslessCrossChainOrder(BatchCompact memory compact_, Mandate memory mandate_, bool deposit) + internal + view + returns (IOriginSettler.GaslessCrossChainOrder memory gaslessCrossChainOrder_) + { + gaslessCrossChainOrder_.originSettler = allocator; + gaslessCrossChainOrder_.user = compact_.sponsor; + gaslessCrossChainOrder_.nonce = compact_.nonce; + gaslessCrossChainOrder_.originChainId = block.chainid; + gaslessCrossChainOrder_.openDeadline = uint32(compact_.expires); + gaslessCrossChainOrder_.fillDeadline = uint32(mandate_.fills[0].expires); + gaslessCrossChainOrder_.orderDataType = ORDERDATA_GASLESS_TYPEHASH; + gaslessCrossChainOrder_.orderData = abi.encode( + IERC7683Allocator.OrderDataGasless({ + order: IERC7683Allocator.Order({ + arbiter: compact_.arbiter, + commitments: compact_.commitments, + mandate: mandate_ + }), + deposit: deposit + }) + ); + return gaslessCrossChainOrder_; + } + + function _manipulateDeposit(IOriginSettler.GaslessCrossChainOrder memory gaslessCrossChainOrder_, bool deposit) + internal + pure + returns (IOriginSettler.GaslessCrossChainOrder memory) + { + bytes memory orderData = gaslessCrossChainOrder_.orderData; + assembly ("memory-safe") { + mstore(add(orderData, 0x60), deposit) + } + return gaslessCrossChainOrder_; + } +} + +abstract contract OnChainCrossChainOrderData is CompactData { + IOriginSettler.OnchainCrossChainOrder private onchainCrossChainOrder; + + function setUp() public virtual override { + super.setUp(); + + BatchCompact memory compact_ = _getCompact(); + Mandate memory mandate_ = _getMandate(); + + onchainCrossChainOrder.fillDeadline = uint32(_getFillExpiration()); + onchainCrossChainOrder.orderDataType = ORDERDATA_ONCHAIN_TYPEHASH; + onchainCrossChainOrder.orderData = abi.encode( + IERC7683Allocator.OrderDataOnChain({ + order: IERC7683Allocator.Order({ + arbiter: compact_.arbiter, + commitments: compact_.commitments, + mandate: mandate_ + }), + expires: uint32(compact_.expires) + }) + ); + } + + function _getOnChainCrossChainOrder() internal view returns (IOriginSettler.OnchainCrossChainOrder memory) { + return onchainCrossChainOrder; + } + + function _getOnChainCrossChainOrder(BatchCompact memory compact_, Mandate memory mandate_) + internal + view + returns (IOriginSettler.OnchainCrossChainOrder memory) + { + IOriginSettler.OnchainCrossChainOrder memory onchainCrossChainOrder_ = IOriginSettler.OnchainCrossChainOrder({ + fillDeadline: uint32(mandate_.fills[0].expires), + orderDataType: ORDERDATA_ONCHAIN_TYPEHASH, + orderData: abi.encode( + IERC7683Allocator.OrderDataOnChain({ + order: IERC7683Allocator.Order({ + arbiter: compact_.arbiter, + commitments: compact_.commitments, + mandate: mandate_ + }), + expires: uint32(compact_.expires) + }) + ) + }); + return onchainCrossChainOrder_; + } +} + +abstract contract Deposited is MocksSetup { + function setUp() public virtual override { + super.setUp(); + + vm.startPrank(user); + + usdc.mint(user, defaultAmount); + usdc.approve(address(compactContract), defaultAmount); + compactContract.depositERC20(address(usdc), usdcLockTag, defaultAmount, user); + + vm.stopPrank(); + } +} diff --git a/test/util/TestHelper.sol b/test/util/TestHelper.sol new file mode 100644 index 0000000..0729f24 --- /dev/null +++ b/test/util/TestHelper.sol @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import {IdLib} from 'lib/the-compact/src/lib/IdLib.sol'; +import {BATCH_COMPACT_TYPEHASH, BatchCompact, LOCK_TYPEHASH, Lock} from 'lib/the-compact/src/types/EIP712Types.sol'; + +import {ResetPeriod} from 'lib/the-compact/src/types/ResetPeriod.sol'; +import {Scope} from 'lib/the-compact/src/types/Scope.sol'; + +contract TestHelper { + string constant BATCH_COMPACT_TYPESTRING_WITH_WITNESS = + 'BatchCompact(address arbiter,address sponsor,uint256 nonce,uint256 expires,Lock[] commitments,Mandate mandate)Lock(bytes12 lockTag,address token,uint256 amount)Mandate(uint256 witness)'; + bytes32 constant BATCH_COMPACT_TYPEHASH_WITH_WITNESS = keccak256(bytes(BATCH_COMPACT_TYPESTRING_WITH_WITNESS)); + string constant WITNESS_STRING = 'uint256 witness'; + string constant WITNESS_TYPESTRING = string(abi.encodePacked('Mandate(', WITNESS_STRING, ')')); + bytes32 constant WITNESS_TYPEHASH = keccak256(bytes(WITNESS_TYPESTRING)); + + function _toLockTag(address allocator, Scope scope, ResetPeriod resetPeriod) + internal + pure + returns (bytes12 lockTag) + { + uint96 allocatorId = _toAllocatorId(allocator); + return IdLib.toLockTag(allocatorId, scope, resetPeriod); + } + + function _toId(Scope scope, ResetPeriod resetPeriod, address allocator, address token) + internal + pure + returns (uint256 id) + { + uint96 allocatorId = _toAllocatorId(allocator); + bytes12 lockTag = IdLib.toLockTag(allocatorId, scope, resetPeriod); + return uint256(uint256(uint96(lockTag)) << 160) | uint256(uint160(token)); + } + + function _toAllocatorId(address allocator) internal pure returns (uint96 allocatorId) { + return IdLib.toAllocatorId(allocator); + } + + function _updateBatchCompact( + BatchCompact memory batchCompact, + uint256[2][] memory idsAndAmounts, + uint256[] memory registeredAmounts, + uint256 nonce + ) internal pure returns (BatchCompact memory) { + batchCompact.commitments = new Lock[](idsAndAmounts.length); + for (uint256 i = 0; i < idsAndAmounts.length; i++) { + batchCompact.commitments[i] = Lock({ + lockTag: bytes12(bytes32(idsAndAmounts[i][0])), + token: address(uint160(idsAndAmounts[i][0])), + amount: registeredAmounts[i] + }); + } + batchCompact.nonce = nonce; + return batchCompact; + } + + function _updateBatchCompact(BatchCompact memory batchCompact, uint256[2][] memory idsAndAmounts, uint256 nonce) + internal + pure + returns (BatchCompact memory) + { + batchCompact.commitments = new Lock[](idsAndAmounts.length); + for (uint256 i = 0; i < idsAndAmounts.length; i++) { + batchCompact.commitments[i] = Lock({ + lockTag: bytes12(bytes32(idsAndAmounts[i][0])), + token: address(uint160(idsAndAmounts[i][0])), + amount: idsAndAmounts[i][1] + }); + } + batchCompact.nonce = nonce; + return batchCompact; + } + + function _toBatchCompactHash(BatchCompact memory batchCompact) internal pure returns (bytes32) { + return keccak256( + abi.encode( + BATCH_COMPACT_TYPEHASH, + batchCompact.arbiter, + batchCompact.sponsor, + batchCompact.nonce, + batchCompact.expires, + _toBatchCompactCommitmentsHash(batchCompact.commitments) + ) + ); + } + + function _toBatchCompactHashWithWitness(bytes32 typeHash, BatchCompact memory batchCompact, bytes32 witness) + internal + pure + returns (bytes32) + { + return keccak256( + abi.encode( + typeHash, + batchCompact.arbiter, + batchCompact.sponsor, + batchCompact.nonce, + batchCompact.expires, + _toBatchCompactCommitmentsHash(batchCompact.commitments), + witness + ) + ); + } + + function _toBatchCompactCommitmentsHash(Lock[] memory commitments) internal pure returns (bytes32) { + bytes32[] memory commitmentsHashes = new bytes32[](commitments.length); + for (uint256 i = 0; i < commitments.length; i++) { + commitmentsHashes[i] = keccak256( + abi.encode(LOCK_TYPEHASH, commitments[i].lockTag, commitments[i].token, commitments[i].amount) + ); + } + return keccak256(abi.encodePacked(commitmentsHashes)); + } + + function _toDigest(bytes32 claimHash, bytes32 domainSeparator) internal pure returns (bytes32) { + return keccak256(abi.encodePacked(bytes2(0x1901), domainSeparator, claimHash)); + } + + function _idsAndAmountsToCommitments(uint256[2][] memory idsAndAmounts) + internal + pure + returns (Lock[] memory commitments) + { + commitments = new Lock[](idsAndAmounts.length); + for (uint256 i = 0; i < idsAndAmounts.length; i++) { + commitments[i] = Lock({ + lockTag: bytes12(bytes32(idsAndAmounts[i][0])), + token: address(uint160(idsAndAmounts[i][0])), + amount: idsAndAmounts[i][1] + }); + } + return commitments; + } +}