diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml index b66798f..a1086d3 100644 --- a/.github/workflows/semgrep.yml +++ b/.github/workflows/semgrep.yml @@ -11,7 +11,7 @@ on: jobs: semgrep: name: semgrep/ci - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest env: SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }} container: diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 90a129f..d0158b5 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -24,7 +24,7 @@ jobs: - name: Install Foundry uses: foundry-rs/foundry-toolchain@v1 with: - version: nightly + version: stable - name: Run Forge build run: | diff --git a/foundry.toml b/foundry.toml index 1009696..f6597d1 100644 --- a/foundry.toml +++ b/foundry.toml @@ -5,14 +5,14 @@ libs = ["lib"] optimizer = true optimizer_runs = 999999 via_ir = true -solc = "0.8.27" +solc = "0.8.28" verbosity = 2 ffi = true +evm_version = "cancun" fs_permissions = [ { access = "read-write", path = ".forge-snapshots"}, { access = "read", path = "script/" } ] - remappings = [ "forge-std=lib/forge-std/src", "@openzeppelin/contracts=lib/openzeppelin-contracts/contracts", @@ -21,13 +21,15 @@ remappings = [ "@solady=lib/solady/src", ] -additional_compiler_profiles = [ - { name = "test", via_ir = false } -] +[profile.ci] +inherit = "default" +optimizer_runs = 200 # Override optimizer runs to reduce the compact contract sizes +bytecode_hash = 'none' -compilation_restrictions = [ - { paths = "test/**", via_ir = false } -] +[profile.pr] +inherit = "default" +optimizer_runs = 200 # Override optimizer runs to reduce the compact contract sizes +bytecode_hash = 'none' [profile.default.fuzz] runs = 1000 diff --git a/src/allocators/ERC7683Allocator.sol b/src/allocators/ERC7683Allocator.sol new file mode 100644 index 0000000..1011b16 --- /dev/null +++ b/src/allocators/ERC7683Allocator.sol @@ -0,0 +1,365 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {IERC7683Allocator} from '../interfaces/IERC7683Allocator.sol'; +import {SimpleAllocator} from './SimpleAllocator.sol'; +import {Claim, Mandate} from './types/TribunalStructs.sol'; + +import {IERC1271} from '@openzeppelin/contracts/interfaces/IERC1271.sol'; +import {ECDSA} from '@openzeppelin/contracts/utils/cryptography/ECDSA.sol'; +import {ITheCompact} from '@uniswap/the-compact/interfaces/ITheCompact.sol'; +import {Compact} from '@uniswap/the-compact/types/EIP712Types.sol'; + +contract ERC7683Allocator is SimpleAllocator, IERC7683Allocator { + /// @notice The typehash of the OrderData struct + // keccak256("OrderData(address arbiter,address sponsor,uint256 nonce,uint256 id,uint256 amount, + // uint256 chainId,address tribunal,address recipient,address token,uint256 minimumAmount,uint256 baselinePriorityFee,uint256 scalingFactor,uint256[] decayCurve,bytes32 salt,uint256 targetBlock,uint256 maximumBlocksAfterTarget)") + bytes32 public constant ORDERDATA_TYPEHASH = 0x9687614112a074c792f7035dc9365f34672a3aa8d3c312500bd47ddcaa0383b5; + + /// @notice The typehash of the OrderDataGasless struct + // keccak256("OrderDataGasless(address arbiter,uint256 id,uint256 amount, + // uint256 chainId,address tribunal,address recipient,address token,uint256 minimumAmount,uint256 baselinePriorityFee,uint256 scalingFactor,uint256[] decayCurve,bytes32 salt)") + bytes32 public constant ORDERDATA_GASLESS_TYPEHASH = + 0xe9b624fa654c7f07ce16d31bf0165a4030d4022f62987afad8ef9d30fc8a0b88; + + /// @notice keccak256("QualifiedClaim(bytes32 claimHash,uint256 targetBlock,uint256 maximumBlocksAfterTarget)") + bytes32 public constant QUALIFICATION_TYPEHASH = 0x59866b84bd1f6c909cf2a31efd20c59e6c902e50f2c196994e5aa85cdc7d7ce0; + + /// @notice keccak256("Compact(address arbiter,address sponsor,uint256 nonce,uint256 expires,uint256 id,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 public constant COMPACT_WITNESS_TYPEHASH = + 0xfd9cda0e5e31a3a3476cb5b57b07e2a4d6a12815506f69c880696448cd9897a5; + + /// @notice keccak256("Mandate(uint256 chainId,address tribunal,address recipient,uint256 expires,address token,uint256 minimumAmount,uint256 baselinePriorityFee,uint256 scalingFactor,uint256[] decayCurve,bytes32 salt)") + bytes32 internal constant MANDATE_TYPEHASH = 0x74d9c10530859952346f3e046aa2981a24bb7524b8394eb45a9deddced9d6501; + + /// @notice uint256(uint8(keccak256("ERC7683Allocator.nonce"))) + uint8 internal constant NONCE_MASTER_SLOT_SEED = 0x39; + + bytes32 immutable _COMPACT_DOMAIN_SEPARATOR; + + constructor(address compactContract_, uint256 minWithdrawalDelay_, uint256 maxWithdrawalDelay_) + SimpleAllocator(compactContract_, minWithdrawalDelay_, maxWithdrawalDelay_) + { + _COMPACT_DOMAIN_SEPARATOR = ITheCompact(COMPACT_CONTRACT).DOMAIN_SEPARATOR(); + } + + /// @inheritdoc IERC7683Allocator + function openFor(GaslessCrossChainOrder calldata order_, bytes calldata sponsorSignature_, bytes calldata) + external + { + // With the users signature, we can create locks in the name of the user + + // Check if orderDataType is the one expected by the allocator + if (order_.orderDataType != ORDERDATA_GASLESS_TYPEHASH) { + revert InvalidOrderDataType(order_.orderDataType, ORDERDATA_GASLESS_TYPEHASH); + } + if (order_.originSettler != address(this)) { + revert InvalidOriginSettler(order_.originSettler, address(this)); + } + + // Decode the orderData + OrderDataGasless memory orderDataGasless = abi.decode(order_.orderData, (OrderDataGasless)); + + OrderData memory orderData = + _convertGaslessOrderData(order_.user, order_.nonce, order_.openDeadline, orderDataGasless); + + _open(orderData, order_.fillDeadline, order_.user, sponsorSignature_); + } + + /// @inheritdoc IERC7683Allocator + function open(OnchainCrossChainOrder calldata order) external { + // Check if orderDataType is the one expected by the allocator + if (order.orderDataType != ORDERDATA_TYPEHASH) { + revert InvalidOrderDataType(order.orderDataType, ORDERDATA_TYPEHASH); + } + + // Decode the orderData + OrderData memory orderData = abi.decode(order.orderData, (OrderData)); + if (orderData.sponsor != msg.sender) { + revert InvalidSponsor(orderData.sponsor, msg.sender); + } + + _open(orderData, order.fillDeadline, msg.sender, ''); + } + + /// @inheritdoc IERC7683Allocator + function resolveFor(GaslessCrossChainOrder calldata order_, bytes calldata) + external + view + returns (ResolvedCrossChainOrder memory) + { + OrderDataGasless memory orderDataGasless = abi.decode(order_.orderData, (OrderDataGasless)); + + OrderData memory orderData = + _convertGaslessOrderData(order_.user, order_.nonce, order_.openDeadline, orderDataGasless); + return _resolveOrder(order_.user, order_.fillDeadline, order_.nonce, orderData, ''); + } + + /// @inheritdoc IERC7683Allocator + function resolve(OnchainCrossChainOrder calldata order_) external view returns (ResolvedCrossChainOrder memory) { + OrderData memory orderData = abi.decode(order_.orderData, (OrderData)); + return _resolveOrder(orderData.sponsor, order_.fillDeadline, orderData.nonce, orderData, ''); + } + + /// @inheritdoc IERC7683Allocator + function getCompactWitnessTypeString() external pure returns (string memory) { + return + 'Compact(address arbiter,address sponsor,uint256 nonce,uint256 expires,uint256 id,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))'; + } + + /// @inheritdoc IERC7683Allocator + function checkNonce(address sponsor_, uint256 nonce_) external view returns (bool nonceFree_) { + uint96 nonceWithoutAddress = _checkNonce(sponsor_, nonce_); + uint96 wordPos = uint96(nonceWithoutAddress / 256); + uint96 bitPos = uint96(nonceWithoutAddress % 256); + assembly ("memory-safe") { + let masterSlot := or(shl(248, NONCE_MASTER_SLOT_SEED), or(shl(88, sponsor_), wordPos)) + nonceFree_ := iszero(and(sload(masterSlot), shl(bitPos, 1))) + } + return nonceFree_; + } + + /// @inheritdoc IERC7683Allocator + function createFillerData(address claimant_) external pure returns (bytes memory fillerData) { + fillerData = abi.encode(claimant_); + return fillerData; + } + + function _open(OrderData memory orderData_, uint32 fillDeadline_, address sponsor_, bytes memory sponsorSignature_) + internal + { + // Enforce a nonce where the most significant 96 bits are the nonce and the least significant 160 bits are the sponsor + uint96 nonceWithoutAddress = _checkNonce(sponsor_, orderData_.nonce); + + // Set a nonce or revert if it is already used + _setNonce(sponsor_, nonceWithoutAddress); + + // We do not enforce a specific tribunal or arbiter. This will allow to support new arbiters and tribunals after the deployment of the allocator + // Going with an immutable arbiter and tribunal would limit support for new chains with a fully decentralized allocator + + bytes32 tokenHash = _lockTokens(orderData_, sponsor_, orderData_.nonce); + + // Work with a Compact digest + bytes32 claimHash = keccak256( + abi.encode( + COMPACT_WITNESS_TYPEHASH, + orderData_.arbiter, + sponsor_, + orderData_.nonce, + orderData_.expires, + orderData_.id, + orderData_.amount, + keccak256( + abi.encode( + MANDATE_TYPEHASH, + orderData_.chainId, + orderData_.tribunal, + orderData_.recipient, + fillDeadline_, + orderData_.token, + orderData_.minimumAmount, + orderData_.baselinePriorityFee, + orderData_.scalingFactor, + keccak256(abi.encodePacked(orderData_.decayCurve)), + orderData_.salt + ) + ) + ) + ); + + // 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 (sponsorSignature_.length > 0) { + bytes32 digest = keccak256(abi.encodePacked(bytes2(0x1901), _COMPACT_DOMAIN_SEPARATOR, claimHash)); + // confirm the signature matches the digest + address signer = ECDSA.recover(digest, sponsorSignature_); + if (sponsor_ != signer) { + revert InvalidSignature(sponsor_, signer); + } + } else { + // confirm the claim hash is registered on the compact + (bool isActive, uint256 registrationExpiration) = + ITheCompact(COMPACT_CONTRACT).getRegistrationStatus(sponsor_, claimHash, COMPACT_WITNESS_TYPEHASH); + if (!isActive || registrationExpiration < orderData_.expires) { + revert InvalidRegistration(sponsor_, claimHash); + } + } + + bytes32 qualifiedClaimHash = keccak256( + abi.encode(QUALIFICATION_TYPEHASH, claimHash, orderData_.targetBlock, orderData_.maximumBlocksAfterTarget) + ); + bytes32 qualifiedDigest = + keccak256(abi.encodePacked(bytes2(0x1901), _COMPACT_DOMAIN_SEPARATOR, qualifiedClaimHash)); + + _sponsor[qualifiedDigest] = tokenHash; + + // Emit an open event + emit Open( + bytes32(orderData_.nonce), + _resolveOrder(sponsor_, fillDeadline_, orderData_.nonce, orderData_, sponsorSignature_) + ); + } + + function _lockTokens(OrderData memory orderData_, address sponsor_, uint256 nonce) + internal + returns (bytes32 tokenHash_) + { + return _lockTokens(orderData_.arbiter, sponsor_, nonce, orderData_.expires, orderData_.id, orderData_.amount); + } + + function _lockTokens(address arbiter, address sponsor, uint256 nonce, uint256 expires, uint256 id, uint256 amount) + internal + returns (bytes32 tokenHash_) + { + tokenHash_ = _checkAllocation( + Compact({arbiter: arbiter, sponsor: sponsor, nonce: nonce, expires: expires, id: id, amount: amount}), false + ); + _claim[tokenHash_] = expires; + _amount[tokenHash_] = amount; + _nonce[tokenHash_] = nonce; + return tokenHash_; + } + + function _resolveOrder( + address sponsor, + uint32 fillDeadline, + uint256 nonce, + OrderData memory orderData, + bytes memory sponsorSignature + ) internal view returns (ResolvedCrossChainOrder memory) { + FillInstruction[] memory fillInstructions = new FillInstruction[](1); + + Mandate memory mandate = Mandate({ + recipient: orderData.recipient, + expires: fillDeadline, + token: orderData.token, + minimumAmount: orderData.minimumAmount, + baselinePriorityFee: orderData.baselinePriorityFee, + scalingFactor: orderData.scalingFactor, + decayCurve: orderData.decayCurve, + salt: orderData.salt + }); + Claim memory claim = Claim({ + chainId: block.chainid, + compact: Compact({ + arbiter: orderData.arbiter, + sponsor: sponsor, + nonce: orderData.nonce, + expires: orderData.expires, + id: orderData.id, + amount: orderData.amount + }), + sponsorSignature: sponsorSignature, + allocatorSignature: '' // No signature required from this allocator, it will verify the claim on chain via ERC1271. + }); + + fillInstructions[0] = FillInstruction({ + destinationChainId: orderData.chainId, + destinationSettler: _addressToBytes32(orderData.tribunal), + originData: abi.encode(claim, mandate, orderData.targetBlock, orderData.maximumBlocksAfterTarget) + }); + + Output memory spent = Output({ + token: _addressToBytes32(orderData.token), + amount: type(uint256).max, + recipient: _addressToBytes32(orderData.recipient), + chainId: orderData.chainId + }); + Output memory received = Output({ + token: _addressToBytes32(_idToToken(orderData.id)), + amount: orderData.amount, + recipient: bytes32(0), + chainId: block.chainid + }); + + Output[] memory maxSpent = new Output[](1); + maxSpent[0] = spent; + Output[] memory minReceived = new Output[](1); + minReceived[0] = received; + + ResolvedCrossChainOrder memory resolvedOrder = ResolvedCrossChainOrder({ + user: sponsor, + originChainId: block.chainid, + openDeadline: uint32(orderData.expires), + fillDeadline: fillDeadline, + orderId: bytes32(nonce), + maxSpent: maxSpent, + minReceived: minReceived, + fillInstructions: fillInstructions + }); + return resolvedOrder; + } + + function _checkNonce(address sponsor_, uint256 nonce_) internal pure returns (uint96 nonce) { + // Enforce a nonce where the least significant 96 bits are the nonce and the most significant 160 bits are the sponsors address + // This ensures that the nonce is unique for a given sponsor + address expectedSponsor; + assembly ("memory-safe") { + expectedSponsor := shr(96, nonce_) + nonce := shr(160, shl(160, nonce_)) + } + if (expectedSponsor != sponsor_) { + revert InvalidNonce(nonce_); + } + } + + function _setNonce(address sponsor_, uint96 nonce_) internal { + bool used; + uint96 wordPos = nonce_ / 256; // uint96 divided by 256 means it becomes a uint88 (11 bytes) + uint96 bitPos = nonce_ % 256; + assembly ("memory-safe") { + // [NONCE_MASTER_SLOT_SEED - 1 byte][sponsor address - 20 bytes][wordPos - 11 bytes] + let masterSlot := or(shl(248, NONCE_MASTER_SLOT_SEED), or(shl(88, sponsor_), wordPos)) + let previouslyUsedNonces := sload(masterSlot) + if and(previouslyUsedNonces, shl(bitPos, 1)) { used := 1 } + { + let usedNonces := or(previouslyUsedNonces, shl(bitPos, 1)) + sstore(masterSlot, usedNonces) + } + } + if (used) { + revert NonceAlreadyInUse(uint256(bytes32(abi.encodePacked(sponsor_, nonce_)))); + } + } + + function _idToToken(uint256 id_) internal pure returns (address token_) { + assembly ("memory-safe") { + token_ := shr(96, shl(96, id_)) + } + } + + function _addressToBytes32(address address_) internal pure returns (bytes32 output_) { + assembly ("memory-safe") { + output_ := shr(96, shl(96, address_)) + } + } + + function _convertGaslessOrderData( + address sponsor_, + uint256 nonce_, + uint32 openDeadline_, + OrderDataGasless memory orderDataGasless_ + ) internal pure returns (OrderData memory orderData_) { + orderData_ = OrderData({ + arbiter: orderDataGasless_.arbiter, + sponsor: sponsor_, + nonce: nonce_, + expires: openDeadline_, + id: orderDataGasless_.id, + amount: orderDataGasless_.amount, + chainId: orderDataGasless_.chainId, + tribunal: orderDataGasless_.tribunal, + recipient: orderDataGasless_.recipient, + token: orderDataGasless_.token, + minimumAmount: orderDataGasless_.minimumAmount, + baselinePriorityFee: orderDataGasless_.baselinePriorityFee, + scalingFactor: orderDataGasless_.scalingFactor, + decayCurve: orderDataGasless_.decayCurve, + salt: orderDataGasless_.salt, + targetBlock: 0, + maximumBlocksAfterTarget: 0 + }); + return orderData_; + } +} diff --git a/src/allocators/SimpleAllocator.sol b/src/allocators/SimpleAllocator.sol new file mode 100644 index 0000000..7eb40a7 --- /dev/null +++ b/src/allocators/SimpleAllocator.sol @@ -0,0 +1,221 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {IAllocator} from '../interfaces/IAllocator.sol'; +import {ISimpleAllocator} from '../interfaces/ISimpleAllocator.sol'; +import {IERC1271} from '@openzeppelin/contracts/interfaces/IERC1271.sol'; +import {ERC6909} from '@solady/tokens/ERC6909.sol'; +import {ITheCompact} from '@uniswap/the-compact/interfaces/ITheCompact.sol'; +import {ResetPeriod} from '@uniswap/the-compact/lib/IdLib.sol'; +import {Compact} from '@uniswap/the-compact/types/EIP712Types.sol'; +import {ForcedWithdrawalStatus} from '@uniswap/the-compact/types/ForcedWithdrawalStatus.sol'; + +contract SimpleAllocator is ISimpleAllocator { + // keccak256("Compact(address arbiter,address sponsor,uint256 nonce,uint256 expires,uint256 id,uint256 amount)") + bytes32 constant COMPACT_TYPEHASH = 0xcdca950b17b5efc016b74b912d8527dfba5e404a688cbc3dab16cb943287fec2; + + address public immutable COMPACT_CONTRACT; + uint256 public immutable MIN_WITHDRAWAL_DELAY; + uint256 public immutable MAX_WITHDRAWAL_DELAY; + + /// @dev mapping of tokenHash to the expiration of the lock + mapping(bytes32 tokenHash => uint256 expiration) internal _claim; + /// @dev mapping of tokenHash to the amount of the lock + mapping(bytes32 tokenHash => uint256 amount) internal _amount; + /// @dev mapping of tokenHash to the nonce of the lock + mapping(bytes32 tokenHash => uint256 nonce) internal _nonce; + /// @dev mapping of the lock digest to the tokenHash of the lock + mapping(bytes32 digest => bytes32 tokenHash) internal _sponsor; + + constructor(address compactContract_, uint256 minWithdrawalDelay_, uint256 maxWithdrawalDelay_) { + COMPACT_CONTRACT = compactContract_; + MIN_WITHDRAWAL_DELAY = minWithdrawalDelay_; + MAX_WITHDRAWAL_DELAY = maxWithdrawalDelay_; + + ITheCompact(COMPACT_CONTRACT).__registerAllocator(address(this), ''); + } + + /// @inheritdoc ISimpleAllocator + function lock(Compact calldata compact_) external { + bytes32 tokenHash = _checkAllocation(compact_, true); + + bytes32 digest = keccak256( + abi.encodePacked( + bytes2(0x1901), + ITheCompact(COMPACT_CONTRACT).DOMAIN_SEPARATOR(), + keccak256( + abi.encode( + COMPACT_TYPEHASH, + compact_.arbiter, + compact_.sponsor, + compact_.nonce, + compact_.expires, + compact_.id, + compact_.amount + ) + ) + ) + ); + + _claim[tokenHash] = compact_.expires; + _amount[tokenHash] = compact_.amount; + _nonce[tokenHash] = compact_.nonce; + _sponsor[digest] = tokenHash; + + emit Locked(compact_.sponsor, compact_.id, compact_.amount, compact_.expires); + } + + /// @inheritdoc IAllocator + function attest(address, address from_, address, uint256 id_, uint256 amount_) external view returns (bytes4) { + if (msg.sender != COMPACT_CONTRACT) { + revert InvalidCaller(msg.sender, COMPACT_CONTRACT); + } + uint256 balance = ERC6909(COMPACT_CONTRACT).balanceOf(from_, id_); + // Check unlocked balance + bytes32 tokenHash = _getTokenHash(id_, from_); + + uint256 fullAmount = amount_; + if (_claim[tokenHash] > block.timestamp) { + // Lock is still active, add the locked amount if the nonce has not yet been consumed + fullAmount += ITheCompact(COMPACT_CONTRACT).hasConsumedAllocatorNonce(_nonce[tokenHash], address(this)) + ? 0 + : _amount[tokenHash]; + } + if (balance < fullAmount) { + revert InsufficientBalance(from_, id_, balance, fullAmount); + } + + return this.attest.selector; + } + + /// @inheritdoc IERC1271 + /// @dev we trust the compact contract to check the nonce is not already consumed + function isValidSignature(bytes32 hash, bytes calldata) external view returns (bytes4 magicValue) { + // The hash is the digest of the compact + bytes32 tokenHash = _sponsor[hash]; + if (tokenHash == bytes32(0) || _claim[tokenHash] <= block.timestamp) { + revert InvalidLock(hash, _claim[tokenHash]); + } + + return IERC1271.isValidSignature.selector; + } + + /// @inheritdoc ISimpleAllocator + function checkTokensLocked(uint256 id_, address sponsor_) + external + view + returns (uint256 amount_, uint256 expires_) + { + bytes32 tokenHash = _getTokenHash(id_, sponsor_); + uint256 expires = _claim[tokenHash]; + if ( + expires <= block.timestamp + || ITheCompact(COMPACT_CONTRACT).hasConsumedAllocatorNonce(_nonce[tokenHash], address(this)) + ) { + return (0, 0); + } + + return (_amount[tokenHash], expires); + } + + /// @inheritdoc ISimpleAllocator + function checkCompactLocked(Compact calldata compact_) external view returns (bool locked_, uint256 expires_) { + bytes32 tokenHash = _getTokenHash(compact_.id, compact_.sponsor); + bytes32 digest = keccak256( + abi.encodePacked( + bytes2(0x1901), + ITheCompact(COMPACT_CONTRACT).DOMAIN_SEPARATOR(), + keccak256( + abi.encode( + COMPACT_TYPEHASH, + compact_.arbiter, + compact_.sponsor, + compact_.nonce, + compact_.expires, + compact_.id, + compact_.amount + ) + ) + ) + ); + uint256 expires = _claim[tokenHash]; + bool active = _sponsor[digest] == tokenHash && expires > block.timestamp + && !ITheCompact(COMPACT_CONTRACT).hasConsumedAllocatorNonce(_nonce[tokenHash], address(this)); + if (active) { + (ForcedWithdrawalStatus status, uint256 forcedWithdrawalAvailableAt) = + ITheCompact(COMPACT_CONTRACT).getForcedWithdrawalStatus(compact_.sponsor, compact_.id); + if (status == ForcedWithdrawalStatus.Enabled && forcedWithdrawalAvailableAt < expires) { + expires = forcedWithdrawalAvailableAt; + active = expires > block.timestamp; + } + } + return (active, active ? expires : 0); + } + + function _getTokenHash(uint256 id_, address sponsor_) internal pure returns (bytes32) { + return keccak256(abi.encode(id_, sponsor_)); + } + + function _checkAllocation(Compact memory compact_, bool checkSponsor_) internal view returns (bytes32) { + // Check msg.sender is sponsor + if (checkSponsor_ && msg.sender != compact_.sponsor) { + revert InvalidCaller(msg.sender, compact_.sponsor); + } + bytes32 tokenHash = _getTokenHash(compact_.id, compact_.sponsor); + // Check no lock is already active for this sponsor + if ( + _claim[tokenHash] > block.timestamp + && !ITheCompact(COMPACT_CONTRACT).hasConsumedAllocatorNonce(_nonce[tokenHash], address(this)) + ) { + revert ClaimActive(compact_.sponsor); + } + // Check expiration is not too soon or too late + if ( + compact_.expires < block.timestamp + MIN_WITHDRAWAL_DELAY + || compact_.expires > block.timestamp + MAX_WITHDRAWAL_DELAY + ) { + revert InvalidExpiration(compact_.expires); + } + (, address allocator, ResetPeriod resetPeriod,) = ITheCompact(COMPACT_CONTRACT).getLockDetails(compact_.id); + if (allocator != address(this)) { + revert InvalidAllocator(allocator); + } + // Check expiration is not longer then the tokens forced withdrawal time + if (compact_.expires > block.timestamp + _resetPeriodToSeconds(resetPeriod)) { + revert ForceWithdrawalAvailable(compact_.expires, block.timestamp + _resetPeriodToSeconds(resetPeriod)); + } + // Check expiration is not past an active force withdrawal + (, uint256 forcedWithdrawalExpiration) = + ITheCompact(COMPACT_CONTRACT).getForcedWithdrawalStatus(compact_.sponsor, compact_.id); + if (forcedWithdrawalExpiration != 0 && forcedWithdrawalExpiration < compact_.expires) { + revert ForceWithdrawalAvailable(compact_.expires, forcedWithdrawalExpiration); + } + // Check nonce is not yet consumed + if (ITheCompact(COMPACT_CONTRACT).hasConsumedAllocatorNonce(compact_.nonce, address(this))) { + revert NonceAlreadyConsumed(compact_.nonce); + } + + uint256 balance = ERC6909(COMPACT_CONTRACT).balanceOf(compact_.sponsor, compact_.id); + // Check balance is enough + if (balance < compact_.amount) { + revert InsufficientBalance(compact_.sponsor, compact_.id, balance, compact_.amount); + } + + return tokenHash; + } + + /// @dev copied from IdLib.sol + function _resetPeriodToSeconds(ResetPeriod resetPeriod_) internal pure returns (uint256 duration) { + assembly ("memory-safe") { + // 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) + } + return duration; + } +} diff --git a/src/allocators/types/TribunalStructs.sol b/src/allocators/types/TribunalStructs.sol new file mode 100644 index 0000000..9d1ffaa --- /dev/null +++ b/src/allocators/types/TribunalStructs.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {Compact} from '@uniswap/the-compact/types/EIP712Types.sol'; + +struct Claim { + uint256 chainId; // Claim processing chain ID + Compact compact; + bytes sponsorSignature; // Authorization from the sponsor + bytes allocatorSignature; // Authorization from the allocator +} + +struct Mandate { + // uint256 chainId; // (implicit arg, included in EIP712 payload). + // address tribunal; // (implicit arg, included in EIP712 payload). + address recipient; // Recipient of filled tokens. + uint256 expires; // Mandate expiration timestamp. + address token; // Fill token (address(0) for native). + uint256 minimumAmount; // Minimum fill amount. + uint256 baselinePriorityFee; // Base fee threshold where scaling kicks in. + uint256 scalingFactor; // Fee scaling multiplier (1e18 baseline). + uint256[] decayCurve; // Block durations, fill increases, & claim decreases. + bytes32 salt; // Replay protection parameter. +} 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/IAllocator.sol b/src/interfaces/IAllocator.sol index 3f187ab..581edb6 100644 --- a/src/interfaces/IAllocator.sol +++ b/src/interfaces/IAllocator.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.27; -import { IERC1271 } from "@openzeppelin/contracts/interfaces/IERC1271.sol"; +import {IERC1271} from '@openzeppelin/contracts/interfaces/IERC1271.sol'; interface IAllocator is IERC1271 { // Called on standard transfers; must return this function selector (0x1a808f91). diff --git a/src/interfaces/IERC7683Allocator.sol b/src/interfaces/IERC7683Allocator.sol new file mode 100644 index 0000000..29b7c21 --- /dev/null +++ b/src/interfaces/IERC7683Allocator.sol @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {IOriginSettler} from './ERC7683/IOriginSettler.sol'; + +interface IERC7683Allocator is IOriginSettler { + struct OrderData { + // COMPACT + 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 id; // The token ID of the ERC6909 token to allocate. + uint256 amount; // The amount of ERC6909 tokens to allocate. + // MANDATE + uint256 chainId; // (implicit arg, included in EIP712 payload) + address tribunal; // (implicit arg, included in EIP712 payload) + address recipient; // Recipient of settled tokens + // uint256 expires; // Mandate expiration timestamp + address token; // Settlement token (address(0) for native) + uint256 minimumAmount; // Minimum settlement amount + uint256 baselinePriorityFee; // Base fee threshold where scaling kicks in + uint256 scalingFactor; // Fee scaling multiplier (1e18 baseline) + uint256[] decayCurve; // Block durations, fill increases, & claim decreases. + bytes32 salt; // Replay protection parameter + // ADDITIONAL INPUT + uint256 targetBlock; // The block number at the target chain on which the PGA is executed / the reverse dutch auction starts. + uint256 maximumBlocksAfterTarget; // Blocks after target block that are still fillable. + } + + struct OrderDataGasless { + // COMPACT + 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 id; // The token ID of the ERC6909 token to allocate. + uint256 amount; // The amount of ERC6909 tokens to allocate. + // MANDATE + uint256 chainId; // (implicit arg, included in EIP712 payload) + address tribunal; // (implicit arg, included in EIP712 payload) + address recipient; // Recipient of settled tokens + // uint256 expires; // Mandate expiration timestamp + address token; // Settlement token (address(0) for native) + uint256 minimumAmount; // Minimum settlement amount + uint256 baselinePriorityFee; // Base fee threshold where scaling kicks in + uint256 scalingFactor; // Fee scaling multiplier (1e18 baseline) + uint256[] decayCurve; // Block durations, fill increases, & claim decreases. + bytes32 salt; // Replay protection parameter + } + + error InvalidOriginSettler(address originSettler, address expectedOriginSettler); + error InvalidOrderDataType(bytes32 orderDataType, bytes32 expectedOrderDataType); + error InvalidNonce(uint256 nonce); + error NonceAlreadyInUse(uint256 nonce); + error InvalidSignature(address signer, address expectedSigner); + error InvalidRegistration(address sponsor, bytes32 claimHash); + error InvalidSponsor(address sponsor, address expectedSponsor); + + /// @inheritdoc IOriginSettler + function openFor(GaslessCrossChainOrder calldata order, bytes calldata signature, bytes calldata originFillerData) + external; + + /// @inheritdoc IOriginSettler + /// @dev Requires the user to have previously registered the claim hash on the compact + function open(OnchainCrossChainOrder calldata order) external; + + /// @inheritdoc IOriginSettler + function resolveFor(GaslessCrossChainOrder calldata order, bytes calldata originFillerData) + external + view + returns (ResolvedCrossChainOrder memory); + + /// @inheritdoc IOriginSettler + function resolve(OnchainCrossChainOrder calldata order) external view returns (ResolvedCrossChainOrder memory); + + /// @notice Returns the type string of the compact including the witness + function getCompactWitnessTypeString() external pure returns (string memory); + + /// @notice Checks if a nonce is free to be used + /// @dev The nonce is the most significant 96 bits. The least significant 160 bits must be the sponsor address + function checkNonce(address sponsor_, uint256 nonce_) external view returns (bool nonceFree_); + + /// @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/ISimpleAllocator.sol b/src/interfaces/ISimpleAllocator.sol new file mode 100644 index 0000000..0e83fd2 --- /dev/null +++ b/src/interfaces/ISimpleAllocator.sol @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {IAllocator} from '../interfaces/IAllocator.sol'; +import {Compact} from '@uniswap/the-compact/types/EIP712Types.sol'; + +interface ISimpleAllocator is IAllocator { + /// @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 NonceAlreadyConsumed(uint256 nonce); + + /// @notice Thrown if the sponsor does not have enough balance to lock the amount + error InsufficientBalance(address sponsor, uint256 id, uint256 balance, uint256 expectedBalance); + + /// @notice Thrown if the provided expiration is not valid + error InvalidExpiration(uint256 expires); + + /// @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(address allocator); + + /// @notice Thrown if the provided lock is not available or expired + /// @dev The expiration will be '0' if no lock is available + error InvalidLock(bytes32 digest, uint256 expiration); + + /// @notice Emitted when a lock is successfully created + /// @param sponsor The address of the sponsor + /// @param id The id of the token + /// @param amount The amount of the token that was available for locking (the full balance of the token will get locked) + /// @param expires The expiration of the lock + event Locked(address indexed sponsor, uint256 indexed id, uint256 amount, uint256 expires); + + /// @notice Locks the tokens of an id for a claim + /// @dev Locks all tokens of a sponsor for an id + /// @param compact_ The compact that contains the data about the lock + function lock(Compact calldata compact_) external; + + /// @notice Checks if the tokens of a sponsor for an id are locked + /// @param id_ The id of the token + /// @param sponsor_ The address of the sponsor + /// @return amount_ The amount of the token that was available for locking (the full balance of the token will get locked) + /// @return expires_ The expiration of the lock + function checkTokensLocked(uint256 id_, address sponsor_) + external + view + returns (uint256 amount_, uint256 expires_); + + /// @notice Checks if the a lock for the compact exists and is active + /// @dev Also checks if the provided nonce has not yet been consumed on the compact contract + /// @param compact_ The compact that contains the data about the lock + /// @return locked_ Whether the compact is locked + /// @return expires_ The expiration of the lock + function checkCompactLocked(Compact calldata compact_) external view returns (bool locked_, uint256 expires_); +} diff --git a/src/test/ERC20Mock.sol b/src/test/ERC20Mock.sol index 61ea4e6..f8a0ce1 100644 --- a/src/test/ERC20Mock.sol +++ b/src/test/ERC20Mock.sol @@ -1,10 +1,10 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.27; -import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {ERC20} from '@openzeppelin/contracts/token/ERC20/ERC20.sol'; contract ERC20Mock is ERC20 { - constructor(string memory name_, string memory symbol_) ERC20(name_, symbol_) { } + 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/TheCompactMock.sol b/src/test/TheCompactMock.sol index 0a63f0d..4dcee68 100644 --- a/src/test/TheCompactMock.sol +++ b/src/test/TheCompactMock.sol @@ -2,14 +2,14 @@ pragma solidity ^0.8.27; -import { IAllocator } from "src/interfaces/IAllocator.sol"; -import { ERC6909 } from "@solady/tokens/ERC6909.sol"; -import { ERC20 } from "@solady/tokens/ERC20.sol"; -import { IdLib } from "@uniswap/the-compact/lib/IdLib.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 { console2 } from "forge-std/console2.sol"; +import {ERC20} from '@solady/tokens/ERC20.sol'; +import {ERC6909} from '@solady/tokens/ERC6909.sol'; +import {IdLib} from '@uniswap/the-compact/lib/IdLib.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 {console2} from 'forge-std/console2.sol'; +import {IAllocator} from 'src/interfaces/IAllocator.sol'; contract TheCompactMock is ERC6909 { using IdLib for uint96; @@ -34,7 +34,7 @@ contract TheCompactMock is ERC6909 { return 0; } - function deposit(address token, uint256 amount, address allocator) external { + function deposit(address token, address allocator, uint256 amount) external { ERC20(token).transferFrom(msg.sender, address(this), amount); uint256 id = _getTokenId(token, allocator); tokens[id] = token; @@ -47,7 +47,9 @@ contract TheCompactMock is ERC6909 { _transfer(address(0), from, to, id, amount); } - function claim(address from, address to, address token, uint256 amount, address allocator, bytes calldata signature) external { + function claim(address from, address to, address token, uint256 amount, address allocator, bytes calldata signature) + external + { uint256 id = _getTokenId(token, allocator); IAllocator(allocator).isValidSignature(keccak256(abi.encode(from, id, amount)), signature); _transfer(address(0), from, to, id, amount); @@ -85,7 +87,11 @@ contract TheCompactMock is ERC6909 { return true; } - function getForcedWithdrawalStatus(address sponsor, uint256) external view returns (ForcedWithdrawalStatus, uint256) { + function getForcedWithdrawalStatus(address sponsor, uint256) + external + view + returns (ForcedWithdrawalStatus, uint256) + { uint256 expires = forcedWithdrawalStatus[sponsor]; return (expires == 0 ? ForcedWithdrawalStatus.Disabled : ForcedWithdrawalStatus.Enabled, expires); } @@ -99,8 +105,8 @@ contract TheCompactMock is ERC6909 { abi.encode( // keccak256('EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)') 0x8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f, - keccak256("The Compact"), - keccak256("0"), + keccak256('The Compact'), + keccak256('0'), block.chainid, address(this) ) @@ -110,22 +116,25 @@ contract TheCompactMock is ERC6909 { function name( uint256 // id ) public view virtual override returns (string memory) { - return "TheCompactMock"; + return 'TheCompactMock'; } function symbol( uint256 // id ) public view virtual override returns (string memory) { - return "TCM"; + return 'TCM'; } function tokenURI( uint256 // id ) public view virtual override returns (string memory) { - return ""; + return ''; } - function _getTokenId(address token, address allocator) internal pure returns (uint256) { - return uint256(keccak256(abi.encode(token, allocator))); + function _getTokenId(address token, address allocator) internal pure returns (uint256 tokenId) { + assembly ("memory-safe") { + tokenId := or(shl(160, allocator), shr(96, shl(96, token))) + } + return tokenId; } } diff --git a/test/ERC7683Allocator.t.sol b/test/ERC7683Allocator.t.sol new file mode 100644 index 0000000..5e45c0b --- /dev/null +++ b/test/ERC7683Allocator.t.sol @@ -0,0 +1,1175 @@ +// 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 {QualifiedClaimWithWitness} from '@uniswap/the-compact/types/Claims.sol'; + +import {COMPACT_TYPEHASH, Compact} from '@uniswap/the-compact/types/EIP712Types.sol'; +import {ForcedWithdrawalStatus} from '@uniswap/the-compact/types/ForcedWithdrawalStatus.sol'; +import {Lock} from '@uniswap/the-compact/types/Lock.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 {console} from 'forge-std/console.sol'; +import {ERC7683Allocator} from 'src/allocators/ERC7683Allocator.sol'; +import {Claim, Mandate} from 'src/allocators/types/TribunalStructs.sol'; +import {IOriginSettler} from 'src/interfaces/ERC7683/IOriginSettler.sol'; +import {IERC7683Allocator} from 'src/interfaces/IERC7683Allocator.sol'; + +import {ISimpleAllocator} from 'src/interfaces/ISimpleAllocator.sol'; +import {ERC20Mock} from 'src/test/ERC20Mock.sol'; +import {TheCompactMock} from 'src/test/TheCompactMock.sol'; + +abstract contract MocksSetup is Test { + address user; + uint256 userPK; + address attacker; + uint256 attackerPK; + address arbiter; + address tribunal; + ERC20Mock usdc; + TheCompact compactContract; + ERC7683Allocator erc7683Allocator; + uint256 usdcId; + + ResetPeriod defaultResetPeriod = ResetPeriod.OneMinute; + Scope defaultScope = Scope.Multichain; + uint256 defaultResetPeriodTimestamp = 60; + uint256 defaultAmount = 1000; + uint256 defaultNonce; + uint256 defaultOutputChainId = 130; + address defaultOutputToken = makeAddr('outputToken'); + uint256 defaultMinimumAmount = 1000; + uint256 defaultBaselinePriorityFee = 0; + uint256 defaultScalingFactor = 0; + uint256[] defaultDecayCurve = new uint256[](0); + bytes32 defaultSalt = bytes32(0); + uint256 defaultTargetBlock = 100; + uint256 defaultMaximumBlocksAfterTarget = 10; + + bytes32 ORDERDATA_GASLESS_TYPEHASH; + bytes32 ORDERDATA_TYPEHASH; + + function setUp() public virtual { + (user, userPK) = makeAddrAndKey('user'); + arbiter = makeAddr('arbiter'); + tribunal = makeAddr('tribunal'); + usdc = new ERC20Mock('USDC', 'USDC'); + compactContract = new TheCompact(); + erc7683Allocator = new ERC7683Allocator(address(compactContract), 5, 100); + Lock memory lock = Lock({ + token: address(usdc), + allocator: address(erc7683Allocator), + resetPeriod: defaultResetPeriod, + scope: defaultScope + }); + usdcId = IdLib.toId(lock); + (attacker, attackerPK) = makeAddrAndKey('attacker'); + defaultNonce = uint256(bytes32(abi.encodePacked(user, uint96(1)))); + + ORDERDATA_GASLESS_TYPEHASH = erc7683Allocator.ORDERDATA_GASLESS_TYPEHASH(); + ORDERDATA_TYPEHASH = erc7683Allocator.ORDERDATA_TYPEHASH(); + } +} + +abstract contract CreateHash is MocksSetup { + struct Allocator { + bytes32 hash; + } + + // 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 = '0'; + + string compactWitnessTypeString = + 'Compact(address arbiter,address sponsor,uint256 nonce,uint256 expires,uint256 id,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)'; + string mandateTypeString = + 'Mandate(uint256 chainId,address tribunal,address recipient,uint256 expires,address token,uint256 minimumAmount,uint256 baselinePriorityFee,uint256 scalingFactor,uint256[] decayCurve,bytes32 salt)'; + string witnessTypeString = + 'Mandate mandate)Mandate(uint256 chainId,address tribunal,address recipient,uint256 expires,address token,uint256 minimumAmount,uint256 baselinePriorityFee,uint256 scalingFactor,uint256[] decayCurve,bytes32 salt)'; + + function _hashCompact(Compact memory data, Mandate memory mandate, address verifyingContract) + internal + view + returns (bytes32) + { + bytes32 compactHash = _hashCompact(data, mandate); + // hash typed data + return keccak256( + abi.encodePacked( + '\x19\x01', // backslash is needed to escape the character + _domainSeparator(verifyingContract), + compactHash + ) + ); + } + + function _hashCompact(Compact memory data, Mandate memory mandate) internal view returns (bytes32 compactHash) { + return keccak256( + abi.encode( + keccak256(bytes(compactWitnessTypeString)), + data.arbiter, + data.sponsor, + data.nonce, + data.expires, + data.id, + data.amount, + keccak256( + abi.encode( + keccak256(bytes(mandateTypeString)), + defaultOutputChainId, + tribunal, + mandate.recipient, + mandate.expires, + mandate.token, + mandate.minimumAmount, + mandate.baselinePriorityFee, + mandate.scalingFactor, + keccak256(abi.encodePacked(mandate.decayCurve)), + mandate.salt + ) + ) + ) + ); + } + + function _getTypeHash() internal view returns (bytes32) { + return keccak256(bytes(compactWitnessTypeString)); + } + + 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(Compact memory data, Mandate memory mandate, address verifyingContract, uint256 signerPK) + internal + view + returns (bytes memory) + { + bytes32 hash = _hashCompact(data, mandate, verifyingContract); + bytes memory signature = _signMessage(hash, signerPK); + return signature; + } +} + +abstract contract CompactData is CreateHash { + Compact private compact; + Mandate private mandate; + + function setUp() public virtual override { + super.setUp(); + + compact = Compact({ + arbiter: arbiter, + sponsor: user, + nonce: defaultNonce, + expires: _getClaimExpiration(), + id: usdcId, + amount: defaultAmount + }); + + mandate = Mandate({ + recipient: user, + expires: _getFillExpiration(), + token: defaultOutputToken, + minimumAmount: defaultMinimumAmount, + baselinePriorityFee: defaultBaselinePriorityFee, + scalingFactor: defaultScalingFactor, + decayCurve: defaultDecayCurve, + salt: defaultSalt + }); + } + + function _getCompact() internal returns (Compact memory) { + compact.expires = _getClaimExpiration(); + return compact; + } + + function _getMandate() internal returns (Mandate memory) { + mandate.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(); + + Compact memory compact_ = _getCompact(); + Mandate memory mandate_ = _getMandate(); + + gaslessCrossChainOrder = IOriginSettler.GaslessCrossChainOrder({ + originSettler: address(erc7683Allocator), + user: compact_.sponsor, + nonce: compact_.nonce, + originChainId: block.chainid, + openDeadline: uint32(_getClaimExpiration()), + fillDeadline: uint32(_getFillExpiration()), + orderDataType: erc7683Allocator.ORDERDATA_GASLESS_TYPEHASH(), + orderData: abi.encode( + IERC7683Allocator.OrderDataGasless({ + arbiter: compact_.arbiter, + id: compact_.id, + amount: compact_.amount, + chainId: defaultOutputChainId, + tribunal: tribunal, + recipient: mandate_.recipient, + token: mandate_.token, + minimumAmount: mandate_.minimumAmount, + baselinePriorityFee: mandate_.baselinePriorityFee, + scalingFactor: mandate_.scalingFactor, + decayCurve: mandate_.decayCurve, + salt: mandate_.salt + }) + ) + }); + } + + function _getGaslessCrossChainOrder( + address allocator, + Compact memory compact_, + Mandate memory mandate_, + uint256 chainId_, + bytes32 orderDataGaslessTypeHash_, + address verifyingContract, + uint256 signerPK + ) internal view returns (IOriginSettler.GaslessCrossChainOrder memory, bytes memory signature) { + IOriginSettler.GaslessCrossChainOrder memory gaslessCrossChainOrder_ = IOriginSettler.GaslessCrossChainOrder({ + originSettler: allocator, + user: compact_.sponsor, + nonce: compact_.nonce, + originChainId: chainId_, + openDeadline: uint32(compact_.expires), + fillDeadline: uint32(mandate_.expires), + orderDataType: orderDataGaslessTypeHash_, + orderData: abi.encode( + IERC7683Allocator.OrderDataGasless({ + arbiter: compact_.arbiter, + id: compact_.id, + amount: compact_.amount, + chainId: defaultOutputChainId, + tribunal: tribunal, + recipient: mandate_.recipient, + token: mandate_.token, + minimumAmount: mandate_.minimumAmount, + baselinePriorityFee: mandate_.baselinePriorityFee, + scalingFactor: mandate_.scalingFactor, + decayCurve: mandate_.decayCurve, + salt: mandate_.salt + }) + ) + }); + + (bytes memory signature_) = _hashAndSign(compact_, mandate_, verifyingContract, signerPK); + return (gaslessCrossChainOrder_, signature_); + } + + function _getGaslessCrossChainOrder() + internal + returns (IOriginSettler.GaslessCrossChainOrder memory, bytes memory signature) + { + (bytes memory signature_) = _hashAndSign(_getCompact(), _getMandate(), address(compactContract), userPK); + return (gaslessCrossChainOrder, signature_); + } +} + +abstract contract OnChainCrossChainOrderData is CompactData { + IOriginSettler.OnchainCrossChainOrder private onchainCrossChainOrder; + + function setUp() public virtual override { + super.setUp(); + + Compact memory compact_ = _getCompact(); + Mandate memory mandate_ = _getMandate(); + + onchainCrossChainOrder = IOriginSettler.OnchainCrossChainOrder({ + fillDeadline: uint32(_getFillExpiration()), + orderDataType: erc7683Allocator.ORDERDATA_TYPEHASH(), + orderData: abi.encode( + IERC7683Allocator.OrderData({ + arbiter: compact_.arbiter, + sponsor: compact_.sponsor, + nonce: compact_.nonce, + expires: compact_.expires, + id: compact_.id, + amount: compact_.amount, + chainId: defaultOutputChainId, + tribunal: tribunal, + recipient: mandate_.recipient, + token: mandate_.token, + minimumAmount: mandate_.minimumAmount, + baselinePriorityFee: mandate_.baselinePriorityFee, + scalingFactor: mandate_.scalingFactor, + decayCurve: mandate_.decayCurve, + salt: mandate_.salt, + targetBlock: defaultTargetBlock, + maximumBlocksAfterTarget: defaultMaximumBlocksAfterTarget + }) + ) + }); + } + + function _getOnChainCrossChainOrder() internal view returns (IOriginSettler.OnchainCrossChainOrder memory) { + return onchainCrossChainOrder; + } + + function _getOnChainCrossChainOrder(Compact memory compact_, Mandate memory mandate_, bytes32 orderDataType_) + internal + view + returns (IOriginSettler.OnchainCrossChainOrder memory) + { + IOriginSettler.OnchainCrossChainOrder memory onchainCrossChainOrder_ = IOriginSettler.OnchainCrossChainOrder({ + fillDeadline: uint32(mandate_.expires), + orderDataType: orderDataType_, + orderData: abi.encode( + IERC7683Allocator.OrderData({ + arbiter: compact_.arbiter, + sponsor: compact_.sponsor, + nonce: compact_.nonce, + expires: compact_.expires, + id: compact_.id, + amount: compact_.amount, + chainId: defaultOutputChainId, + tribunal: tribunal, + recipient: mandate_.recipient, + token: mandate_.token, + minimumAmount: mandate_.minimumAmount, + baselinePriorityFee: mandate_.baselinePriorityFee, + scalingFactor: mandate_.scalingFactor, + decayCurve: mandate_.decayCurve, + salt: mandate_.salt, + targetBlock: defaultTargetBlock, + maximumBlocksAfterTarget: defaultMaximumBlocksAfterTarget + }) + ) + }); + 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.deposit( + address(usdc), address(erc7683Allocator), defaultResetPeriod, defaultScope, defaultAmount, user + ); + + vm.stopPrank(); + } +} + +contract ERC7683Allocator_openFor is GaslessCrossChainOrderData { + 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, + erc7683Allocator.ORDERDATA_GASLESS_TYPEHASH() + ) + ); + (IOriginSettler.GaslessCrossChainOrder memory falseGaslessCrossChainOrder, bytes memory signature) = + _getGaslessCrossChainOrder(); + falseGaslessCrossChainOrder.orderDataType = falseOrderDataType; + erc7683Allocator.openFor(falseGaslessCrossChainOrder, signature, ''); + } + + function test_revert_InvalidDecoding() public { + // Decoding fails because of additional data + vm.prank(user); + vm.expectRevert(); + (IOriginSettler.GaslessCrossChainOrder memory falseGaslessCrossChainOrder, bytes memory signature) = + _getGaslessCrossChainOrder(); + falseGaslessCrossChainOrder.orderData = abi.encode(falseGaslessCrossChainOrder.orderData, uint8(1)); + erc7683Allocator.openFor(falseGaslessCrossChainOrder, signature, ''); + } + + 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, bytes memory signature) = + _getGaslessCrossChainOrder( + falseOriginSettler, + _getCompact(), + _getMandate(), + block.chainid, + ORDERDATA_GASLESS_TYPEHASH, + address(erc7683Allocator), + userPK + ); + vm.prank(user); + erc7683Allocator.openFor(falseGaslessCrossChainOrder, signature, ''); + } + + function test_revert_InvalidNonce() public { + // Nonce is invalid because the least significant 160 bits are not the sponsor address + Compact memory compact_ = _getCompact(); + compact_.nonce = uint256(bytes32(abi.encodePacked(uint96(1), attacker))); + vm.expectRevert(abi.encodeWithSelector(IERC7683Allocator.InvalidNonce.selector, compact_.nonce)); + (IOriginSettler.GaslessCrossChainOrder memory falseGaslessCrossChainOrder, bytes memory signature) = + _getGaslessCrossChainOrder( + address(erc7683Allocator), + compact_, + _getMandate(), + block.chainid, + ORDERDATA_GASLESS_TYPEHASH, + address(erc7683Allocator), + userPK + ); + vm.prank(user); + erc7683Allocator.openFor(falseGaslessCrossChainOrder, signature, ''); + } + + function test_revert_InvalidSponsorSignature() public { + // Sponsor signature is invalid + + // Deposit tokens + vm.startPrank(user); + usdc.mint(user, defaultAmount); + usdc.approve(address(compactContract), defaultAmount); + compactContract.deposit( + address(usdc), address(erc7683Allocator), defaultResetPeriod, defaultScope, defaultAmount, user + ); + vm.stopPrank(); + + // Create a malicious signature + (IOriginSettler.GaslessCrossChainOrder memory gaslessCrossChainOrder_, bytes memory sponsorSignature) = + _getGaslessCrossChainOrder( + address(erc7683Allocator), + _getCompact(), + _getMandate(), + block.chainid, + ORDERDATA_GASLESS_TYPEHASH, + address(compactContract), + attackerPK + ); + vm.prank(user); + vm.expectRevert(abi.encodeWithSelector(IERC7683Allocator.InvalidSignature.selector, user, attacker)); + erc7683Allocator.openFor(gaslessCrossChainOrder_, sponsorSignature, ''); + } + + function test_successful_userHimself() public { + // Deposit tokens + vm.startPrank(user); + usdc.mint(user, defaultAmount); + usdc.approve(address(compactContract), defaultAmount); + compactContract.deposit( + address(usdc), address(erc7683Allocator), defaultResetPeriod, defaultScope, defaultAmount, user + ); + vm.stopPrank(); + + (IOriginSettler.GaslessCrossChainOrder memory gaslessCrossChainOrder_, bytes memory sponsorSignature) = + _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 + }); + Claim memory claim = Claim({ + 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(user); + vm.expectEmit(true, false, false, true, address(erc7683Allocator)); + emit IOriginSettler.Open(bytes32(defaultNonce), resolvedCrossChainOrder); + erc7683Allocator.openFor(gaslessCrossChainOrder_, sponsorSignature, ''); + } + + function test_successful_relayed() public { + // Deposit tokens + vm.startPrank(user); + usdc.mint(user, defaultAmount); + usdc.approve(address(compactContract), defaultAmount); + compactContract.deposit( + address(usdc), address(erc7683Allocator), defaultResetPeriod, defaultScope, defaultAmount, user + ); + vm.stopPrank(); + + (IOriginSettler.GaslessCrossChainOrder memory gaslessCrossChainOrder_, bytes memory sponsorSignature) = + _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 + }); + Claim memory claim = Claim({ + 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(makeAddr('filler')); + vm.expectEmit(true, false, false, true, address(erc7683Allocator)); + emit IOriginSettler.Open(bytes32(defaultNonce), resolvedCrossChainOrder); + erc7683Allocator.openFor(gaslessCrossChainOrder_, sponsorSignature, ''); + } + + function test_revert_NonceAlreadyInUse() public { + // Nonce is already used + + // Deposit tokens + vm.startPrank(user); + usdc.mint(user, defaultAmount); + usdc.approve(address(compactContract), defaultAmount); + compactContract.deposit( + address(usdc), address(erc7683Allocator), defaultResetPeriod, defaultScope, defaultAmount, user + ); + vm.stopPrank(); + + // use the nonce once + (IOriginSettler.GaslessCrossChainOrder memory gaslessCrossChainOrder_, bytes memory sponsorSignature) = + _getGaslessCrossChainOrder(); + vm.prank(user); + erc7683Allocator.openFor(gaslessCrossChainOrder_, sponsorSignature, ''); + + // try to use the nonce again + (IOriginSettler.GaslessCrossChainOrder memory gaslessCrossChainOrder2, bytes memory sponsorSignature2) = + _getGaslessCrossChainOrder(); + vm.prank(user); + vm.expectRevert(abi.encodeWithSelector(IERC7683Allocator.NonceAlreadyInUse.selector, defaultNonce)); + erc7683Allocator.openFor(gaslessCrossChainOrder2, sponsorSignature2, ''); + } +} + +contract ERC7683Allocator_open is OnChainCrossChainOrderData { + 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, + erc7683Allocator.ORDERDATA_TYPEHASH() + ) + ); + erc7683Allocator.open(onChainCrossChainOrder_); + } + + function test_revert_InvalidSponsor() public { + IOriginSettler.OnchainCrossChainOrder memory onChainCrossChainOrder_ = _getOnChainCrossChainOrder(); + + vm.prank(attacker); + vm.expectRevert(abi.encodeWithSelector(IERC7683Allocator.InvalidSponsor.selector, user, attacker)); + erc7683Allocator.open(onChainCrossChainOrder_); + } + + function test_revert_InvalidRegistration_Unavailable() public { + // we deposit tokens + vm.startPrank(user); + usdc.mint(user, defaultAmount); + usdc.approve(address(compactContract), defaultAmount); + compactContract.deposit( + address(usdc), address(erc7683Allocator), defaultResetPeriod, defaultScope, defaultAmount, user + ); + + // we do NOT register a claim + + vm.stopPrank(); + + (IOriginSettler.OnchainCrossChainOrder memory onChainCrossChainOrder_) = _getOnChainCrossChainOrder(); + + Compact memory compact_ = _getCompact(); + Mandate memory mandate_ = _getMandate(); + bytes32 claimHash = _hashCompact(compact_, mandate_); + + vm.prank(user); + vm.expectRevert(abi.encodeWithSelector(IERC7683Allocator.InvalidRegistration.selector, user, claimHash)); + erc7683Allocator.open(onChainCrossChainOrder_); + } + + function test_revert_InvalidRegistration_Expired() public { + // we deposit tokens + vm.startPrank(user); + usdc.mint(user, defaultAmount); + usdc.approve(address(compactContract), defaultAmount); + compactContract.deposit( + address(usdc), address(erc7683Allocator), defaultResetPeriod, defaultScope, defaultAmount, user + ); + + // we register a claim with a expiration that is too short + Compact memory compact_ = _getCompact(); + Mandate memory mandate_ = _getMandate(); + + bytes32 claimHash = _hashCompact(compact_, mandate_); + bytes32 typeHash = _getTypeHash(); + compactContract.register(claimHash, typeHash, defaultResetPeriodTimestamp - 1); + + vm.stopPrank(); + (IOriginSettler.OnchainCrossChainOrder memory onChainCrossChainOrder_) = _getOnChainCrossChainOrder(); + + vm.prank(user); + vm.expectRevert(abi.encodeWithSelector(IERC7683Allocator.InvalidRegistration.selector, user, claimHash)); + erc7683Allocator.open(onChainCrossChainOrder_); + } + + function test_successful() public { + // Deposit tokens + vm.startPrank(user); + usdc.mint(user, defaultAmount); + usdc.approve(address(compactContract), defaultAmount); + compactContract.deposit( + address(usdc), address(erc7683Allocator), defaultResetPeriod, defaultScope, defaultAmount, user + ); + + // register a claim + Compact memory compact_ = _getCompact(); + Mandate memory mandate_ = _getMandate(); + + bytes32 claimHash = _hashCompact(compact_, mandate_); + bytes32 typeHash = _getTypeHash(); + compactContract.register(claimHash, typeHash, defaultResetPeriodTimestamp); + + 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 + }); + Claim memory claim = + Claim({chainId: block.chainid, compact: _getCompact(), sponsorSignature: '', allocatorSignature: ''}); + fillInstructions[0] = IOriginSettler.FillInstruction({ + destinationChainId: defaultOutputChainId, + destinationSettler: bytes32(uint256(uint160(tribunal))), + originData: abi.encode(claim, _getMandate(), defaultTargetBlock, defaultMaximumBlocksAfterTarget) + }); + + 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, true, address(erc7683Allocator)); + emit IOriginSettler.Open(bytes32(defaultNonce), resolvedCrossChainOrder); + erc7683Allocator.open(onChainCrossChainOrder_); + } +} + +contract ERC7683Allocator_isValidSignature is OnChainCrossChainOrderData, GaslessCrossChainOrderData { + function setUp() public override(OnChainCrossChainOrderData, GaslessCrossChainOrderData) { + super.setUp(); + } + + function test_revert_InvalidLock() public { + // Deposit tokens + vm.startPrank(user); + usdc.mint(user, defaultAmount); + usdc.approve(address(compactContract), defaultAmount); + compactContract.deposit( + address(usdc), address(erc7683Allocator), defaultResetPeriod, defaultScope, defaultAmount, user + ); + + // register a claim + Compact memory compact_ = _getCompact(); + Mandate memory mandate_ = _getMandate(); + + bytes32 claimHash = _hashCompact(compact_, mandate_); + bytes32 typeHash = _getTypeHash(); + compactContract.register(claimHash, typeHash, defaultResetPeriodTimestamp); + + 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 be fail, because we mess with the nonce + QualifiedClaimWithWitness memory claim = QualifiedClaimWithWitness({ + allocatorSignature: '', + sponsorSignature: '', + sponsor: user, + nonce: defaultNonce, + expires: compact_.expires, + witness: keccak256(abi.encode(keccak256(bytes(mandateTypeString)), mandate_)), + witnessTypestring: witnessTypeString, + qualificationTypehash: erc7683Allocator.QUALIFICATION_TYPEHASH(), + qualificationPayload: abi.encode(defaultTargetBlock, defaultMaximumBlocksAfterTarget), + id: usdcId, + allocatedAmount: defaultAmount, + claimant: filler, + amount: defaultAmount + }); + vm.prank(arbiter); + vm.expectRevert(abi.encodeWithSelector(0x8baa579f)); // check for the InvalidSignature() error in the Compact contract + compactContract.claim(claim); + + vm.assertEq(compactContract.balanceOf(user, usdcId), defaultAmount); + vm.assertEq(compactContract.balanceOf(filler, usdcId), 0); + } + + function test_isValidSignature_successful_open() public { + // Deposit tokens + vm.startPrank(user); + usdc.mint(user, defaultAmount); + usdc.approve(address(compactContract), defaultAmount); + compactContract.deposit( + address(usdc), address(erc7683Allocator), defaultResetPeriod, defaultScope, defaultAmount, user + ); + + // register a claim + Compact memory compact_ = _getCompact(); + Mandate memory mandate_ = _getMandate(); + + bytes32 claimHash = _hashCompact(compact_, mandate_); + bytes32 typeHash = _getTypeHash(); + compactContract.register(claimHash, typeHash, defaultResetPeriodTimestamp); + + address filler = makeAddr('filler'); + vm.assertEq(compactContract.balanceOf(user, usdcId), defaultAmount); + vm.assertEq(compactContract.balanceOf(filler, usdcId), 0); + + // we open the order and lock the tokens + (IOriginSettler.OnchainCrossChainOrder memory onChainCrossChainOrder_) = _getOnChainCrossChainOrder(); + erc7683Allocator.open(onChainCrossChainOrder_); + vm.stopPrank(); + + // claim should be successful + QualifiedClaimWithWitness memory claim = QualifiedClaimWithWitness({ + allocatorSignature: '', + sponsorSignature: '', + sponsor: user, + nonce: defaultNonce, + expires: compact_.expires, + witness: keccak256( + abi.encode( + keccak256(bytes(mandateTypeString)), + defaultOutputChainId, + tribunal, + mandate_.recipient, + mandate_.expires, + mandate_.token, + mandate_.minimumAmount, + mandate_.baselinePriorityFee, + mandate_.scalingFactor, + keccak256(abi.encodePacked(mandate_.decayCurve)), + mandate_.salt + ) + ), + witnessTypestring: witnessTypeString, + qualificationTypehash: erc7683Allocator.QUALIFICATION_TYPEHASH(), + qualificationPayload: abi.encode(defaultTargetBlock, defaultMaximumBlocksAfterTarget), + id: usdcId, + allocatedAmount: defaultAmount, + claimant: filler, + amount: defaultAmount + }); + vm.prank(arbiter); + compactContract.claim(claim); + + vm.assertEq(compactContract.balanceOf(user, usdcId), 0); + vm.assertEq(compactContract.balanceOf(filler, usdcId), defaultAmount); + } + + function test_isValidSignature_successful_openFor() public { + // Deposit tokens + vm.startPrank(user); + usdc.mint(user, defaultAmount); + usdc.approve(address(compactContract), defaultAmount); + compactContract.deposit( + address(usdc), address(erc7683Allocator), defaultResetPeriod, defaultScope, defaultAmount, user + ); + + // register a claim + Compact memory compact_ = _getCompact(); + Mandate memory mandate_ = _getMandate(); + + bytes32 claimHash = _hashCompact(compact_, mandate_); + bytes32 typeHash = _getTypeHash(); + compactContract.register(claimHash, typeHash, defaultResetPeriodTimestamp); + + address filler = makeAddr('filler'); + vm.assertEq(compactContract.balanceOf(user, usdcId), defaultAmount); + vm.assertEq(compactContract.balanceOf(filler, usdcId), 0); + + // we open the order and lock the tokens + (IOriginSettler.GaslessCrossChainOrder memory gaslessCrossChainOrder_, bytes memory sponsorSignature) = + _getGaslessCrossChainOrder(); + erc7683Allocator.openFor(gaslessCrossChainOrder_, sponsorSignature, ''); + vm.stopPrank(); + + // claim should be successful + QualifiedClaimWithWitness memory claim = QualifiedClaimWithWitness({ + allocatorSignature: '', + sponsorSignature: '', + sponsor: user, + nonce: defaultNonce, + expires: compact_.expires, + witness: keccak256( + abi.encode( + keccak256(bytes(mandateTypeString)), + defaultOutputChainId, + tribunal, + mandate_.recipient, + mandate_.expires, + mandate_.token, + mandate_.minimumAmount, + mandate_.baselinePriorityFee, + mandate_.scalingFactor, + keccak256(abi.encodePacked(mandate_.decayCurve)), + mandate_.salt + ) + ), + witnessTypestring: witnessTypeString, + qualificationTypehash: erc7683Allocator.QUALIFICATION_TYPEHASH(), + qualificationPayload: abi.encode(uint256(0), uint256(0)), + id: usdcId, + allocatedAmount: defaultAmount, + claimant: filler, + amount: defaultAmount + }); + vm.prank(arbiter); + compactContract.claim(claim); + + vm.assertEq(compactContract.balanceOf(user, usdcId), 0); + vm.assertEq(compactContract.balanceOf(filler, usdcId), defaultAmount); + } +} + +contract ERC7683Allocator_resolveFor is GaslessCrossChainOrderData { + 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_, /*bytes memory sponsorSignature*/ ) = + _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 + }); + Claim memory claim = Claim({ + chainId: block.chainid, + compact: _getCompact(), + 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(), 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 + }); + 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 OnChainCrossChainOrderData { + 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 + }); + Claim memory claim = + Claim({chainId: block.chainid, compact: _getCompact(), sponsorSignature: '', allocatorSignature: ''}); + fillInstructions[0] = IOriginSettler.FillInstruction({ + destinationChainId: defaultOutputChainId, + destinationSettler: bytes32(uint256(uint160(tribunal))), + originData: abi.encode(claim, _getMandate(), defaultTargetBlock, defaultMaximumBlocksAfterTarget) + }); + + 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.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 MocksSetup { + function test_getCompactWitnessTypeString() public view { + assertEq( + erc7683Allocator.getCompactWitnessTypeString(), + 'Compact(address arbiter,address sponsor,uint256 nonce,uint256 expires,uint256 id,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))' + ); + } +} + +contract ERC7683Allocator_checkNonce is OnChainCrossChainOrderData { + function test_revert_invalidNonce(uint256 nonce_) public { + address expectedSponsor; + assembly ("memory-safe") { + expectedSponsor := shr(96, nonce_) + } + vm.assume(user != expectedSponsor); + + vm.expectRevert(abi.encodeWithSelector(IERC7683Allocator.InvalidNonce.selector, nonce_)); + erc7683Allocator.checkNonce(user, nonce_); + } + + function test_checkNonce_unused(uint96 nonce_) public view { + address sponsor = user; + uint256 nonce; + assembly ("memory-safe") { + nonce := or(shl(96, sponsor), shr(160, shl(160, nonce_))) + } + assertEq(erc7683Allocator.checkNonce(sponsor, nonce), true); + } + + function test_checkNonce_used() public { + // Deposit tokens + vm.startPrank(user); + usdc.mint(user, defaultAmount); + usdc.approve(address(compactContract), defaultAmount); + compactContract.deposit( + address(usdc), address(erc7683Allocator), defaultResetPeriod, defaultScope, defaultAmount, user + ); + + // register a claim + Compact memory compact_ = _getCompact(); + Mandate memory mandate_ = _getMandate(); + + bytes32 claimHash = _hashCompact(compact_, mandate_); + bytes32 typeHash = _getTypeHash(); + compactContract.register(claimHash, typeHash, defaultResetPeriodTimestamp); + + (IOriginSettler.OnchainCrossChainOrder memory onChainCrossChainOrder_) = _getOnChainCrossChainOrder(); + erc7683Allocator.open(onChainCrossChainOrder_); + + vm.assertEq(erc7683Allocator.checkNonce(user, defaultNonce), false); + vm.stopPrank(); + } + + function test_checkNonce_fuzz(uint8 nonce_) public { + uint256 nonce = uint256(bytes32(abi.encodePacked(user, uint96(nonce_)))); + + bool sameNonce = nonce == defaultNonce; + + // Deposit tokens + vm.startPrank(user); + usdc.mint(user, defaultAmount); + usdc.approve(address(compactContract), defaultAmount); + compactContract.deposit( + address(usdc), address(erc7683Allocator), defaultResetPeriod, defaultScope, defaultAmount, user + ); + + // register a claim + Compact memory compact_ = _getCompact(); + Mandate memory mandate_ = _getMandate(); + + bytes32 claimHash = _hashCompact(compact_, mandate_); + bytes32 typeHash = _getTypeHash(); + compactContract.register(claimHash, typeHash, defaultResetPeriodTimestamp); + + (IOriginSettler.OnchainCrossChainOrder memory onChainCrossChainOrder_) = _getOnChainCrossChainOrder(); + erc7683Allocator.open(onChainCrossChainOrder_); + + vm.assertEq(erc7683Allocator.checkNonce(user, nonce), !sameNonce); + + vm.stopPrank(); + } +} diff --git a/test/SimpleAllocator.t.sol b/test/SimpleAllocator.t.sol new file mode 100644 index 0000000..3f8bbf9 --- /dev/null +++ b/test/SimpleAllocator.t.sol @@ -0,0 +1,784 @@ +// 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 {ITheCompact} from '@uniswap/the-compact/interfaces/ITheCompact.sol'; +import {COMPACT_TYPEHASH, Compact} from '@uniswap/the-compact/types/EIP712Types.sol'; +import {ForcedWithdrawalStatus} from '@uniswap/the-compact/types/ForcedWithdrawalStatus.sol'; +import {Test} from 'forge-std/Test.sol'; + +import {console} from 'forge-std/console.sol'; +import {SimpleAllocator} from 'src/allocators/SimpleAllocator.sol'; +import {ISimpleAllocator} from 'src/interfaces/ISimpleAllocator.sol'; +import {ERC20Mock} from 'src/test/ERC20Mock.sol'; +import {TheCompactMock} from 'src/test/TheCompactMock.sol'; + +abstract contract MocksSetup is Test { + address user; + uint256 userPK; + address attacker; + uint256 attackerPK; + address arbiter; + ERC20Mock usdc; + TheCompactMock compactContract; + SimpleAllocator simpleAllocator; + uint256 usdcId; + + uint256 defaultResetPeriod = 60; + uint256 defaultAmount = 1000; + uint256 defaultNonce = 1; + uint256 defaultExpiration; + + function setUp() public virtual { + arbiter = makeAddr('arbiter'); + usdc = new ERC20Mock('USDC', 'USDC'); + compactContract = new TheCompactMock(); + simpleAllocator = new SimpleAllocator(address(compactContract), 5, 100); + usdcId = compactContract.getTokenId(address(usdc), address(simpleAllocator)); + (user, userPK) = makeAddrAndKey('user'); + (attacker, attackerPK) = makeAddrAndKey('attacker'); + } +} + +abstract contract CreateHash is Test { + struct Allocator { + bytes32 hash; + } + + // stringified types + string EIP712_DOMAIN_TYPE = 'EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)'; // Hashed inside the funcion + // EIP712 domain type + string name = 'The Compact'; + string version = '0'; + + function _hashCompact(Compact memory data, address verifyingContract) internal view returns (bytes32) { + // hash typed data + return keccak256( + abi.encodePacked( + '\x19\x01', // backslash is needed to escape the character + _domainSeparator(verifyingContract), + keccak256( + abi.encode( + COMPACT_TYPEHASH, data.arbiter, data.sponsor, data.nonce, data.expires, data.id, data.amount + ) + ) + ) + ); + } + + 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); + } +} + +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.deposit(address(usdc), address(simpleAllocator), defaultAmount); + + vm.stopPrank(); + } +} + +abstract contract Locked is Deposited { + function setUp() public virtual override { + super.setUp(); + + vm.startPrank(user); + + defaultExpiration = vm.getBlockTimestamp() + defaultResetPeriod; + simpleAllocator.lock( + Compact({ + arbiter: arbiter, + sponsor: user, + nonce: defaultNonce, + id: usdcId, + expires: defaultExpiration, + amount: defaultAmount + }) + ); + + vm.stopPrank(); + } +} + +contract SimpleAllocator_Lock is MocksSetup { + function test_revert_InvalidCaller() public { + vm.prank(user); + vm.expectRevert(abi.encodeWithSelector(ISimpleAllocator.InvalidCaller.selector, user, attacker)); + simpleAllocator.lock( + Compact({ + arbiter: arbiter, + sponsor: attacker, + nonce: 1, + id: usdcId, + expires: block.timestamp + 1, + amount: 1000 + }) + ); + } + + function test_revert_ClaimActive() public { + vm.startPrank(user); + + // Mint, approve and deposit + usdc.mint(user, defaultAmount); + usdc.approve(address(compactContract), defaultAmount); + compactContract.deposit(address(usdc), address(simpleAllocator), defaultAmount); + + // Successfully locked + simpleAllocator.lock( + Compact({ + arbiter: arbiter, + sponsor: user, + nonce: defaultNonce, + id: usdcId, + expires: block.timestamp + defaultResetPeriod, + amount: defaultAmount + }) + ); + + vm.warp(block.timestamp + defaultResetPeriod - 1); + + vm.expectRevert(abi.encodeWithSelector(ISimpleAllocator.ClaimActive.selector, user)); + simpleAllocator.lock( + Compact({ + arbiter: arbiter, + sponsor: user, + nonce: defaultNonce + 1, + id: usdcId, + expires: block.timestamp + defaultResetPeriod, + amount: defaultAmount + }) + ); + } + + function test_revert_InvalidExpiration_tooShort(uint128 delay_) public { + delay_ = uint128(bound(delay_, 0, simpleAllocator.MIN_WITHDRAWAL_DELAY() - 1)); + uint256 expiration = vm.getBlockTimestamp() + delay_; + vm.prank(user); + vm.expectRevert(abi.encodeWithSelector(ISimpleAllocator.InvalidExpiration.selector, expiration)); + simpleAllocator.lock( + Compact({ + arbiter: arbiter, + sponsor: user, + nonce: 1, + id: usdcId, + expires: vm.getBlockTimestamp() + delay_, + amount: 1000 + }) + ); + } + + function test_revert_InvalidExpiration_tooLong(uint128 delay_) public { + vm.assume(delay_ > simpleAllocator.MAX_WITHDRAWAL_DELAY()); + uint256 expiration = vm.getBlockTimestamp() + delay_; + vm.prank(user); + vm.expectRevert(abi.encodeWithSelector(ISimpleAllocator.InvalidExpiration.selector, expiration)); + simpleAllocator.lock( + Compact({ + arbiter: arbiter, + sponsor: user, + nonce: 1, + id: usdcId, + expires: vm.getBlockTimestamp() + delay_, + amount: 1000 + }) + ); + } + + function test_revert_ForceWithdrawalAvailable_ExpirationLongerThenResetPeriod(uint8 delay_) public { + // Use bound to ensure delay_ is within valid range but greater than resetPeriod + delay_ = uint8( + bound( + delay_, + simpleAllocator.MIN_WITHDRAWAL_DELAY() > defaultResetPeriod + ? simpleAllocator.MIN_WITHDRAWAL_DELAY() + 1 + : defaultResetPeriod + 1, + simpleAllocator.MAX_WITHDRAWAL_DELAY() - 1 + ) + ); + + uint256 expiration = vm.getBlockTimestamp() + delay_; + uint256 maxExpiration = vm.getBlockTimestamp() + defaultResetPeriod; + vm.prank(user); + vm.expectRevert( + abi.encodeWithSelector(ISimpleAllocator.ForceWithdrawalAvailable.selector, expiration, maxExpiration) + ); + simpleAllocator.lock( + Compact({arbiter: arbiter, sponsor: user, nonce: 1, id: usdcId, expires: expiration, amount: 1000}) + ); + } + + function test_revert_ForceWithdrawalAvailable_ScheduledForceWithdrawal() public { + vm.startPrank(user); + compactContract.enableForceWithdrawal(usdcId); + + // move time forward + vm.warp(vm.getBlockTimestamp() + 1); + + // This expiration should be fine, if the force withdrawal was not enabled + uint256 expiration = vm.getBlockTimestamp() + defaultResetPeriod; + // check force withdrawal + (ForcedWithdrawalStatus status, uint256 expires) = compactContract.getForcedWithdrawalStatus(user, usdcId); + assertEq(status == ForcedWithdrawalStatus.Enabled, true); + assertEq(expires, expiration - 1); + + vm.expectRevert( + abi.encodeWithSelector(ISimpleAllocator.ForceWithdrawalAvailable.selector, expiration, expiration - 1) + ); + simpleAllocator.lock( + Compact({arbiter: arbiter, sponsor: user, nonce: 1, id: usdcId, expires: expiration, amount: 1000}) + ); + } + + function test_revert_ForceWithdrawalAvailable_ActiveForceWithdrawal() public { + vm.startPrank(user); + compactContract.enableForceWithdrawal(usdcId); + + // move time forward + uint256 forceWithdrawalTimestamp = vm.getBlockTimestamp() + defaultResetPeriod; + vm.warp(forceWithdrawalTimestamp); + + // This expiration should be fine, if the force withdrawal was not enabled + uint256 expiration = vm.getBlockTimestamp() + defaultResetPeriod; + // check force withdrawal + (ForcedWithdrawalStatus status, uint256 expires) = compactContract.getForcedWithdrawalStatus(user, usdcId); + assertEq(status == ForcedWithdrawalStatus.Enabled, true); + assertEq(expires, forceWithdrawalTimestamp); + + vm.expectRevert( + abi.encodeWithSelector( + ISimpleAllocator.ForceWithdrawalAvailable.selector, expiration, forceWithdrawalTimestamp + ) + ); + simpleAllocator.lock( + Compact({arbiter: arbiter, sponsor: user, nonce: 1, id: usdcId, expires: expiration, amount: 1000}) + ); + } + + function test_revert_NonceAlreadyConsumed(uint256 nonce_) public { + vm.startPrank(user); + uint256[] memory nonces = new uint256[](1); + nonces[0] = nonce_; + compactContract.consume(nonces); + assertEq(compactContract.hasConsumedAllocatorNonce(nonce_, address(simpleAllocator)), true); + + vm.expectRevert(abi.encodeWithSelector(ISimpleAllocator.NonceAlreadyConsumed.selector, nonce_)); + simpleAllocator.lock( + Compact({ + arbiter: arbiter, + sponsor: user, + nonce: nonce_, + id: usdcId, + expires: block.timestamp + defaultResetPeriod, + amount: 1000 + }) + ); + } + + function test_revert_InsufficientBalance(uint256 balance_, uint256 amount_) public { + vm.assume(balance_ < amount_); + + vm.startPrank(user); + + // Mint, approve and deposit + usdc.mint(user, balance_); + usdc.approve(address(compactContract), balance_); + compactContract.deposit(address(usdc), address(simpleAllocator), balance_); + + // Check balance + assertEq(compactContract.balanceOf(user, usdcId), balance_); + + vm.expectRevert( + abi.encodeWithSelector(ISimpleAllocator.InsufficientBalance.selector, user, usdcId, balance_, amount_) + ); + simpleAllocator.lock( + Compact({ + arbiter: arbiter, + sponsor: user, + nonce: 1, + id: usdcId, + expires: block.timestamp + defaultResetPeriod, + amount: amount_ + }) + ); + } + + function test_successfullyLocked(uint256 nonce_, uint128 amount_, uint32 delay_) public { + delay_ = uint32( + bound( + delay_, + simpleAllocator.MIN_WITHDRAWAL_DELAY() + 1, + defaultResetPeriod < simpleAllocator.MAX_WITHDRAWAL_DELAY() + ? defaultResetPeriod + : simpleAllocator.MAX_WITHDRAWAL_DELAY() - 1 + ) + ); + + vm.startPrank(user); + + // Mint, approve and deposit + usdc.mint(user, amount_); + usdc.approve(address(compactContract), amount_); + compactContract.deposit(address(usdc), address(simpleAllocator), amount_); + + // Check no lock exists + (uint256 amountBefore, uint256 expiresBefore) = simpleAllocator.checkTokensLocked(usdcId, user); + + assertEq(amountBefore, 0); + assertEq(expiresBefore, 0); + + uint256 expiration = vm.getBlockTimestamp() + delay_; + vm.expectEmit(true, true, false, true); + emit ISimpleAllocator.Locked(user, usdcId, amount_, expiration); + simpleAllocator.lock( + Compact({arbiter: arbiter, sponsor: user, nonce: nonce_, id: usdcId, expires: expiration, amount: amount_}) + ); + + // Check lock exists + (uint256 amountAfter, uint256 expiresAfter) = simpleAllocator.checkTokensLocked(usdcId, user); + + assertEq(amountAfter, amount_); + assertEq(expiresAfter, expiration); + } + + function test_successfullyLocked_AfterNonceConsumption( + uint256 nonce_, + uint256 noncePrev_, + uint128 amount_, + uint32 delay_ + ) public { + delay_ = uint32( + bound( + delay_, + simpleAllocator.MIN_WITHDRAWAL_DELAY() + 1, + defaultResetPeriod < simpleAllocator.MAX_WITHDRAWAL_DELAY() + ? defaultResetPeriod + : simpleAllocator.MAX_WITHDRAWAL_DELAY() - 1 + ) + ); + vm.assume(noncePrev_ != nonce_); + + vm.startPrank(user); + + // Mint, approve and deposit + usdc.mint(user, amount_); + usdc.approve(address(compactContract), amount_); + compactContract.deposit(address(usdc), address(simpleAllocator), amount_); + + // Create a previous lock + uint256 expirationPrev = vm.getBlockTimestamp() + delay_; + vm.expectEmit(true, true, false, true); + emit ISimpleAllocator.Locked(user, usdcId, amount_, expirationPrev); + simpleAllocator.lock( + Compact({ + arbiter: arbiter, + sponsor: user, + nonce: noncePrev_, + id: usdcId, + expires: expirationPrev, + amount: amount_ + }) + ); + + // Check a previous lock exists + (uint256 amountBefore, uint256 expiresBefore) = simpleAllocator.checkTokensLocked(usdcId, user); + assertEq(amountBefore, amount_); + assertEq(expiresBefore, expirationPrev); + + // Check for revert if previous nonce not consumed + uint256 expiration = vm.getBlockTimestamp() + delay_; + + vm.expectRevert(abi.encodeWithSelector(ISimpleAllocator.ClaimActive.selector, user)); + simpleAllocator.lock( + Compact({arbiter: arbiter, sponsor: user, nonce: nonce_, id: usdcId, expires: expiration, amount: amount_}) + ); + + // Consume previous nonce + uint256[] memory nonces = new uint256[](1); + nonces[0] = noncePrev_; + vm.stopPrank(); + vm.prank(address(simpleAllocator)); + compactContract.consume(nonces); + + vm.prank(user); + + vm.expectEmit(true, true, false, true); + emit ISimpleAllocator.Locked(user, usdcId, amount_, expiration); + simpleAllocator.lock( + Compact({arbiter: arbiter, sponsor: user, nonce: nonce_, id: usdcId, expires: expiration, amount: amount_}) + ); + + // Check lock exists + (uint256 amountAfter, uint256 expiresAfter) = simpleAllocator.checkTokensLocked(usdcId, user); + + assertEq(amountAfter, amount_); + assertEq(expiresAfter, expiration); + } +} + +contract SimpleAllocator_Attest is Deposited { + function test_revert_InvalidCaller_NotCompact() public { + vm.prank(attacker); + vm.expectRevert( + abi.encodeWithSelector(ISimpleAllocator.InvalidCaller.selector, attacker, address(compactContract)) + ); + simpleAllocator.attest(address(user), address(user), address(usdc), usdcId, defaultAmount); + } + + function test_revert_InsufficientBalance_NoActiveLock(uint128 falseAmount_) public { + vm.assume(falseAmount_ > defaultAmount); + + vm.prank(user); + vm.expectRevert( + abi.encodeWithSelector( + ISimpleAllocator.InsufficientBalance.selector, user, usdcId, defaultAmount, falseAmount_ + ) + ); + compactContract.transfer(user, attacker, falseAmount_, address(usdc), address(simpleAllocator)); + } + + function test_revert_InsufficientBalance_ActiveLock() public { + vm.startPrank(user); + + // Lock a single token + uint256 defaultExpiration_ = vm.getBlockTimestamp() + defaultResetPeriod; + simpleAllocator.lock( + Compact({ + arbiter: arbiter, + sponsor: user, + nonce: defaultNonce, + id: usdcId, + expires: defaultExpiration_, + amount: 1 + }) + ); + + // At this point, the deposited defaultAmount is not fully available anymore, because one of the tokens was locked + + // Revert if we try to transfer all of the deposited tokens + vm.expectRevert( + abi.encodeWithSelector( + ISimpleAllocator.InsufficientBalance.selector, user, usdcId, defaultAmount, defaultAmount + 1 + ) + ); + compactContract.transfer(user, attacker, defaultAmount, address(usdc), address(simpleAllocator)); + } + + function test_successfullyAttested_returnsSelector() public { + bytes4 selector = bytes4(0x1a808f91); + + uint32 transferAmount = 10; + uint32 lockedAmount = 90; + + address otherUser = makeAddr('otherUser'); + + // Lock tokens + uint256 defaultExpiration_ = vm.getBlockTimestamp() + defaultResetPeriod; + vm.prank(user); + simpleAllocator.lock( + Compact({ + arbiter: arbiter, + sponsor: user, + nonce: defaultNonce, + id: usdcId, + expires: defaultExpiration_, + amount: lockedAmount + }) + ); + compactContract.transfer(user, otherUser, transferAmount, address(usdc), address(simpleAllocator)); + + vm.prank(address(compactContract)); + bytes4 returnedSelector = simpleAllocator.attest(user, user, otherUser, usdcId, transferAmount); + assertEq(returnedSelector, selector); + } + + function test_successfullyAttested(uint32 lockedAmount_, uint32 transferAmount_) public { + transferAmount_ = uint32(bound(transferAmount_, 0, defaultAmount)); + lockedAmount_ = uint32(bound(lockedAmount_, 0, defaultAmount - transferAmount_)); + + address otherUser = makeAddr('otherUser'); + + vm.startPrank(user); + // Lock tokens + uint256 defaultExpiration_ = vm.getBlockTimestamp() + defaultResetPeriod; + simpleAllocator.lock( + Compact({ + arbiter: arbiter, + sponsor: user, + nonce: defaultNonce, + id: usdcId, + expires: defaultExpiration_, + amount: lockedAmount_ + }) + ); + + vm.expectEmit(true, true, true, true); + emit ERC6909.Transfer(address(0), user, otherUser, usdcId, transferAmount_); + compactContract.transfer(user, otherUser, transferAmount_, address(usdc), address(simpleAllocator)); + + // Check that the other user has the tokens + assertEq(compactContract.balanceOf(otherUser, usdcId), transferAmount_); + assertEq(compactContract.balanceOf(user, usdcId), defaultAmount - transferAmount_); + } +} + +contract SimpleAllocator_IsValidSignature is Deposited, CreateHash { + function test_revert_InvalidLock_NoActiveLock() public { + bytes32 digest = _hashCompact( + Compact({ + arbiter: arbiter, + sponsor: user, + nonce: defaultNonce, + id: usdcId, + expires: block.timestamp + defaultResetPeriod, + amount: defaultAmount + }), + address(compactContract) + ); + + vm.expectRevert(abi.encodeWithSelector(ISimpleAllocator.InvalidLock.selector, digest, 0)); + simpleAllocator.isValidSignature(digest, ''); + } + + function test_revert_InvalidLock_ExpiredLock() public { + vm.startPrank(user); + + // Lock tokens + uint256 defaultExpiration_ = vm.getBlockTimestamp() + defaultResetPeriod; + simpleAllocator.lock( + Compact({ + arbiter: arbiter, + sponsor: user, + nonce: defaultNonce, + id: usdcId, + expires: defaultExpiration_, + amount: defaultAmount + }) + ); + + // Move time forward so lock has expired + vm.warp(block.timestamp + defaultResetPeriod); + + bytes32 digest = _hashCompact( + Compact({ + arbiter: arbiter, + sponsor: user, + nonce: defaultNonce, + id: usdcId, + expires: defaultExpiration_, + amount: defaultAmount + }), + address(compactContract) + ); + + vm.expectRevert(abi.encodeWithSelector(ISimpleAllocator.InvalidLock.selector, digest, defaultExpiration_)); + simpleAllocator.isValidSignature(digest, ''); + } + + function test_successfullyValidated() public { + vm.startPrank(user); + + // Lock tokens + uint256 defaultExpiration_ = vm.getBlockTimestamp() + defaultResetPeriod; + simpleAllocator.lock( + Compact({ + arbiter: arbiter, + sponsor: user, + nonce: defaultNonce, + id: usdcId, + expires: defaultExpiration_, + amount: defaultAmount + }) + ); + + // Move time forward so lock has expired + vm.warp(block.timestamp + defaultResetPeriod - 1); + + bytes32 digest = _hashCompact( + Compact({ + arbiter: arbiter, + sponsor: user, + nonce: defaultNonce, + id: usdcId, + expires: defaultExpiration_, + amount: defaultAmount + }), + address(compactContract) + ); + + bytes4 selector = simpleAllocator.isValidSignature(digest, ''); + assertEq(selector, IERC1271.isValidSignature.selector); + } +} + +contract SimpleAllocator_CheckTokensLocked is Locked { + function test_checkTokensLocked_NoActiveLock() public { + address otherUser = makeAddr('otherUser'); + (uint256 amount, uint256 expires) = simpleAllocator.checkTokensLocked(usdcId, otherUser); + assertEq(amount, 0); + assertEq(expires, 0); + } + + function test_checkTokensLocked_ExpiredLock() public { + (uint256 amount, uint256 expires) = simpleAllocator.checkTokensLocked(usdcId, user); + assertEq(amount, defaultAmount); + assertEq(expires, defaultExpiration); + + vm.warp(defaultExpiration); + + (amount, expires) = simpleAllocator.checkTokensLocked(usdcId, user); + assertEq(amount, 0); + assertEq(expires, 0); + } + + function test_checkTokensLocked_NonceConsumed() public { + (uint256 amount, uint256 expires) = simpleAllocator.checkTokensLocked(usdcId, user); + assertEq(amount, defaultAmount); + assertEq(expires, defaultExpiration); + + uint256[] memory nonces = new uint256[](1); + nonces[0] = defaultNonce; + vm.prank(address(simpleAllocator)); + compactContract.consume(nonces); + + (amount, expires) = simpleAllocator.checkTokensLocked(usdcId, user); + assertEq(amount, 0); + assertEq(expires, 0); + } + + function test_checkTokensLocked_ActiveLock() public { + vm.warp(defaultExpiration - 1); + + (uint256 amount, uint256 expires) = simpleAllocator.checkTokensLocked(usdcId, user); + assertEq(amount, defaultAmount); + assertEq(expires, defaultExpiration); + } + + function test_checkCompactLocked_NoActiveLock() public { + address otherUser = makeAddr('otherUser'); + (bool locked, uint256 expires) = simpleAllocator.checkCompactLocked( + Compact({ + arbiter: arbiter, + sponsor: otherUser, + nonce: defaultNonce, + id: usdcId, + expires: defaultExpiration, + amount: defaultAmount + }) + ); + assertEq(locked, false); + assertEq(expires, 0); + } + + function test_checkCompactLocked_ExpiredLock() public { + // Confirm that a lock is previously active + (bool locked, uint256 expires) = simpleAllocator.checkCompactLocked( + Compact({ + arbiter: arbiter, + sponsor: user, + nonce: defaultNonce, + id: usdcId, + expires: defaultExpiration, + amount: defaultAmount + }) + ); + assertEq(locked, true); + assertEq(expires, defaultExpiration); + + // Move time forward so lock has expired + vm.warp(defaultExpiration); + + // Check that the lock is no longer active + (locked, expires) = simpleAllocator.checkCompactLocked( + Compact({ + arbiter: arbiter, + sponsor: user, + nonce: defaultNonce, + id: usdcId, + expires: defaultExpiration, + amount: defaultAmount + }) + ); + assertEq(locked, false); + assertEq(expires, 0); + } + + function test_checkCompactLocked_NonceConsumed() public { + // Confirm that a lock is previously active + (bool locked, uint256 expires) = simpleAllocator.checkCompactLocked( + Compact({ + arbiter: arbiter, + sponsor: user, + nonce: defaultNonce, + id: usdcId, + expires: defaultExpiration, + amount: defaultAmount + }) + ); + assertEq(locked, true); + assertEq(expires, defaultExpiration); + + // Consume nonce + uint256[] memory nonces = new uint256[](1); + nonces[0] = defaultNonce; + vm.prank(address(simpleAllocator)); + compactContract.consume(nonces); + + // Check that the lock is no longer active + (locked, expires) = simpleAllocator.checkCompactLocked( + Compact({ + arbiter: arbiter, + sponsor: user, + nonce: defaultNonce, + id: usdcId, + expires: defaultExpiration, + amount: defaultAmount + }) + ); + assertEq(locked, false); + assertEq(expires, 0); + } + + function test_checkCompactLocked_successfully() public { + // Move time forward to last second before expiration + vm.warp(defaultExpiration - 1); + + // Confirm that a lock is active + (bool locked, uint256 expires) = simpleAllocator.checkCompactLocked( + Compact({ + arbiter: arbiter, + sponsor: user, + nonce: defaultNonce, + id: usdcId, + expires: defaultExpiration, + amount: defaultAmount + }) + ); + assertEq(locked, true); + assertEq(expires, defaultExpiration); + } + + // Check a force withdrawal will impact the expiration +}