From 701947f555a1594f9d9e3c3ec7b1fbce5f22617d Mon Sep 17 00:00:00 2001 From: mgretzke Date: Mon, 15 Dec 2025 11:07:19 +0100 Subject: [PATCH 1/4] AdditionalCommitments HybridAllocator --- src/allocators/HybridAllocator.sol | 170 +++++++++++++++++++-- src/allocators/OnChainAllocator.sol | 3 +- src/allocators/lib/AllocatorLib.sol | 47 ++++-- src/allocators/lib/ERC7683AllocatorLib.sol | 6 +- src/interfaces/IHybridAllocator.sol | 10 +- src/interfaces/IOnChainAllocator.sol | 4 +- 6 files changed, 212 insertions(+), 28 deletions(-) diff --git a/src/allocators/HybridAllocator.sol b/src/allocators/HybridAllocator.sol index 303b323..220bda8 100644 --- a/src/allocators/HybridAllocator.sol +++ b/src/allocators/HybridAllocator.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.27; import {SafeTransferLib} from '@solady/utils/SafeTransferLib.sol'; -import {Lock} from '@uniswap/the-compact/types/EIP712Types.sol'; +import {LOCK_TYPEHASH, Lock} from '@uniswap/the-compact/types/EIP712Types.sol'; import {IERC20} from '@openzeppelin/contracts/token/ERC20/IERC20.sol'; @@ -29,6 +29,11 @@ contract HybridAllocator is IHybridAllocator { event OwnerReplaced(address oldOwner, address newOwner); event AllocatorInitialized(address compact, address owner, uint96 allocatorId); + /// @dev The typehash for the HybridAllocationContext: + /// keccak256('HybridAllocationContext(bytes32 claimHash,uint256 nonce,Lock[] additionalCommitments)Lock(bytes12 lockTag,address token,uint256 amount)') + bytes32 constant HYBRID_ALLOCATION_CONTEXT_TYPEHASH = + 0x6aa08e172ed524dc2013404e602ede105a3158471f628286084d00f175913ee4; + /// @notice The unique identifier for this allocator within The Compact protocol uint96 public immutable ALLOCATOR_ID; uint256 private immutable _INITIAL_CHAIN_ID; @@ -197,7 +202,8 @@ contract HybridAllocator is IHybridAllocator { bytes32 claimHash, string calldata witness, bytes32 witnessHash, - bytes calldata signature + bytes calldata signature, + bytes calldata context ) external returns (Lock[] memory commitments) { commitments = AL.permit2Allocation( arbiter, depositor, expires, permitted, details, claimHash, witness, witnessHash, signature @@ -217,15 +223,28 @@ contract HybridAllocator is IHybridAllocator { uint256 expires, bytes32 typehash, bytes32 witness, - bytes calldata /* orderData */ + bytes calldata context ) external returns (uint256 nonce) { - uint88 nonce88 = nonces + 1; - - nonce = - AL.prepareAllocation(nonce88, recipient, idsAndAmounts, arbiter, expires, typehash, witness, ALLOCATOR_ID); + if (context.length > 0) { + // Potential off chain nonce provided + HybridAllocationContext calldata allocationContext = _decodeContext(idsAndAmounts.length, context); + + // Verify the nonce is scoped to an off chain allocation and to the recipient + AL.verifyNonce(allocationContext.nonce, AL.OFF_CHAIN_NONCE, recipient); + + AL.prepareAllocation( + allocationContext.nonce, recipient, idsAndAmounts, arbiter, expires, typehash, witness, ALLOCATOR_ID + ); + } else { + // No off chain nonce provided, use an on chain nonce + uint88 nonce88 = nonces + 1; + nonce = AL.getNonceWithCommand(AL.ON_CHAIN_NONCE, nonce88); + AL.prepareAllocation(nonce, recipient, idsAndAmounts, arbiter, expires, typehash, witness, ALLOCATOR_ID); + } } - /// @inheritdoc IOnChainAllocation + // TODO: THINK ABOUT IF THE uint256[] calldata additionalCommitmentAmounts should be a default parameter. A registration must confirm the users intentions and the contract must make sure that the additional commitments are unallocated. + // Either by checking an off chain signature, or on chain by checking the unallocated balances function executeAllocation( address recipient, uint256[2][] calldata idsAndAmounts, @@ -233,12 +252,45 @@ contract HybridAllocator is IHybridAllocator { uint256 expires, bytes32 typehash, bytes32 witness, - bytes calldata /* orderData */ + bytes calldata context ) external { - uint88 nonce88 = ++nonces; + bytes32 claimHash; + Lock[] memory commitments; + uint256 nonce; + + if (context.length > 0) { + // Off chain nonce and additional commitments amounts provided + HybridAllocationContext calldata allocationContext = _decodeContext(idsAndAmounts.length, context); + + // Verify the nonce is scoped to an off chain allocation and to the recipient + AL.verifyNonce(allocationContext.nonce, AL.OFF_CHAIN_NONCE, recipient); + + (claimHash, commitments) = AL.executeAllocation( + allocationContext.nonce, + recipient, + idsAndAmounts, + allocationContext.additionalCommitmentAmounts, + arbiter, + expires, + typehash, + witness + ); + + // Validate the signers signature for the hybrid allocation context + _validateContext(commitments, allocationContext, claimHash); + } else { + // No off chain nonce provided, use an on chain nonce + uint88 nonce88 = ++nonces; + nonce = AL.getNonceWithCommand(AL.ON_CHAIN_NONCE, nonce88); + + (claimHash, commitments) = + AL.executeAllocation(nonce, recipient, idsAndAmounts, arbiter, expires, typehash, witness); + } - (bytes32 claimHash, Lock[] memory commitments, uint256 nonce) = - AL.executeAllocation(nonce88, recipient, idsAndAmounts, arbiter, expires, typehash, witness); + if (claims[claimHash]) { + // If the claim was already allocated, skip the allocation and the event emission + return; + } // Allocate the claim claims[claimHash] = true; @@ -372,4 +424,98 @@ contract HybridAllocator is IHybridAllocator { digest := keccak256(add(m, 0x1e), 0x42) } } + + function _decodeContext(uint256 commitmentsLength, bytes calldata context) + internal + pure + returns (HybridAllocationContext calldata allocationContext) + { + assembly ("memory-safe") { + // HybridAllocationContext structure: + // 0x00: HybridAllocationContext.offset + // 0x20: claimHash + // 0x40: nonce + // 0x60: additionalCoAm.offset + // 0x80: signature.offset + // 0xa0: additionalCoAm.length (must match commitments.length) + // 0xc0: additionalCommitments.content + // 0xc0 + (commitments.length * 0x20): signature.length (must be 64 or 65 bytes) + // 0xe0 + (commitments.length * 0x20): signature.content + + // required length must be 0x100 + (commitments.length * 0x20) + signature length of 64 or 96 bytes (65 bytes will be padded to 96 bytes) + + let requiredLength := 0x100 + let lengthOfCommitments := mul(0x20, commitmentsLength) + requiredLength := add(requiredLength, lengthOfCommitments) // must match commitments.length + requiredLength := add(requiredLength, 0x40) // compact signature of 64 bytes minimum length as minimum length + + let errorBuffer := or(lt(context.length, requiredLength), gt(context.length, add(requiredLength, 0x20))) // check length of context is valid + errorBuffer := or(errorBuffer, xor(calldataload(add(context.offset, 0x60)), 0x80)) // check additionalCoAm offset is valid (offset relative to context.offset) + errorBuffer := or(errorBuffer, xor(calldataload(add(context.offset, 0xa0)), commitmentsLength)) // check additionalCoAm length is valid (must match commitments.length) + errorBuffer := or(errorBuffer, xor(calldataload(add(context.offset, 0x80)), add(0xa0, lengthOfCommitments))) // check signature offset is valid (offset relative to context.offset) + + // Check the signature is valid + let calldataSignatureLength := calldataload(add(context.offset, add(0xc0, lengthOfCommitments))) + errorBuffer := or(errorBuffer, or(lt(calldataSignatureLength, 0x40), gt(calldataSignatureLength, 0x41))) // check signature length is valid (must be 64 or 65 bytes) + if errorBuffer { revert(0x00, 0x00) } + + allocationContext := add(context.offset, 0x20) + } + } + + function _validateContext( + Lock[] memory commitments, + HybridAllocationContext calldata allocationContext, + bytes32 claimHash + ) internal view { + bytes32 hybridAllocationHash; + bytes32[] memory commitmentsHashes = new bytes32[](commitments.length); + uint256[] calldata additionalCommitmentAmounts = allocationContext.additionalCommitmentAmounts; + + // Create the hybrid allocation context hash + assembly ("memory-safe") { + // hybrid allocation context hash: + // 0x00: typehash + // 0x20: claimHash + // 0x40: nonce + // 0x60: additionalCommitments hash + + let m := mload(0x40) + mstore(m, HYBRID_ALLOCATION_CONTEXT_TYPEHASH) // typehash + mstore(add(m, 0x20), claimHash) // claimHash + mstore(add(m, 0x40), calldataload(allocationContext)) // nonce + + // Create the commitments hash + // Use the commitments lockTag and token, but the amount from HybridAllocationContext.additionalCommitmentAmounts + let freeMemoryPointer := add(m, 0x80) + let commitmentsLength := mload(commitments) + // Populate all thecommitmentHashes + mstore(freeMemoryPointer, LOCK_TYPEHASH) + for { let i := 0 } lt(i, commitmentsLength) { i := add(i, 1) } { + let commitmentOffset := mload(add(add(commitments, 0x20), mul(i, 0x20))) + mstore(add(freeMemoryPointer, 0x20), mload(commitmentOffset)) // lockTag from commitments + mstore(add(freeMemoryPointer, 0x40), mload(add(commitmentOffset, 0x20))) // token from commitments + mstore( + add(freeMemoryPointer, 0x60), calldataload(add(additionalCommitmentAmounts.offset, mul(i, 0x20))) + ) // amount from HybridAllocationContext.additionalCommitmentAmounts + let commitmentsHashPointer := add(add(commitmentsHashes, 0x20 /* skip length */ ), mul(i, 0x20)) + mstore(commitmentsHashPointer, keccak256(freeMemoryPointer, 0x80)) + } + + // Create the commitments hash: keccak256(abi.encodePacked(commitmentsHashes)) + mstore( + add(m, 0x60), keccak256(add(commitmentsHashes, 0x20 /* skip length */ ), mul(commitmentsLength, 0x20)) + ) + + hybridAllocationHash := keccak256(m, 0x80) + } + bytes32 digest = _deriveDigest(hybridAllocationHash, _COMPACT_DOMAIN_SEPARATOR); + if (block.chainid != _INITIAL_CHAIN_ID) { + // If the chain was forked, we can not use the cached domain separator + digest = _deriveDigest(claimHash, ITheCompact(AL.THE_COMPACT).DOMAIN_SEPARATOR()); + } + if (!_checkSignature(digest, allocationContext.signature)) { + revert InvalidSignature(); + } + } } diff --git a/src/allocators/OnChainAllocator.sol b/src/allocators/OnChainAllocator.sol index ba66522..b0a7d18 100644 --- a/src/allocators/OnChainAllocator.sol +++ b/src/allocators/OnChainAllocator.sol @@ -253,7 +253,8 @@ contract OnChainAllocator is IOnChainAllocator, Utility { bytes32 claimHash, string calldata witness, bytes32 witnessHash, - bytes calldata signature + bytes calldata signature, + bytes calldata /* context */ ) external returns (Lock[] memory commitments) { if (expires > type(uint32).max) { revert InvalidExpiration(expires, type(uint32).max); diff --git a/src/allocators/lib/AllocatorLib.sol b/src/allocators/lib/AllocatorLib.sol index 3b7319f..69d559a 100644 --- a/src/allocators/lib/AllocatorLib.sol +++ b/src/allocators/lib/AllocatorLib.sol @@ -210,7 +210,7 @@ library AllocatorLib { } function prepareAllocation( - uint248 noncePreCommand, + uint256 nonce, address recipient, uint256[2][] calldata idsAndAmounts, address arbiter, @@ -218,12 +218,10 @@ library AllocatorLib { bytes32 typehash, bytes32 witness, uint96 allocatorId - ) internal returns (uint256 nonce) { + ) internal { // Before preparing the allocation, check if the compact's reentrancy guard is active checkCompactReentrancyGuardAndRevert(); - nonce = getNonceWithCommand(ON_CHAIN_NONCE, noncePreCommand); - assembly ("memory-safe") { // identifier = keccak256(abi.encode(PREPARE_ALLOCATION_SELECTOR, recipient, ids, arbiter, expires, typehash, witness)); let memoryPointer := mload(0x40) @@ -281,21 +279,37 @@ library AllocatorLib { } function executeAllocation( - uint248 noncePreCommand, + uint256 nonce, address recipient, uint256[2][] calldata idsAndAmounts, address arbiter, uint256 expires, bytes32 typehash, bytes32 witness - ) internal view returns (bytes32 claimHash, Lock[] memory, uint256 nonce) { + ) internal view returns (bytes32 claimHash, Lock[] memory) { + uint256[] memory additionalCommitmentAmounts = new uint256[](idsAndAmounts.length); + + return executeAllocation( + nonce, recipient, idsAndAmounts, additionalCommitmentAmounts, arbiter, expires, typehash, witness + ); + } + + /// @dev Additional commitment amounts MUST be unallocated, which IS NOT verified by this library. + function executeAllocation( + uint256 nonce, + address recipient, + uint256[2][] calldata idsAndAmounts, + uint256[] calldata additionalCommitmentAmounts, + address arbiter, + uint256 expires, + bytes32 typehash, + bytes32 witness + ) internal view returns (bytes32 claimHash, Lock[] memory) { bytes32[] memory commitmentHashes = new bytes32[](idsAndAmounts.length); Lock[] memory commitments = new Lock[](idsAndAmounts.length); bytes32 commitmentsHash; uint256 storedNonce; - nonce = getNonceWithCommand(ON_CHAIN_NONCE, noncePreCommand); - // Before executing the allocation, check if the compact's reentrancy guard is active checkCompactReentrancyGuardAndRevert(); @@ -345,6 +359,21 @@ library AllocatorLib { } let diffBalance := sub(currentBalance, oldBalance) + // Add the additional commitment amount. + let additionalCommitmentAmount := calldataload(add(additionalCommitmentAmounts.offset, mul(i, 0x20))) + diffBalance := add(diffBalance, additionalCommitmentAmount) + if gt(diffBalance, currentBalance) { + /// @dev This is NOT a sufficient check to guarantee the user has enough unallocated tokens available for the additional commitment. + /// The additional committed amounts MUST be verified by the implementation of the allocator library. + /// This check will only be used to check if enough tokens are generally available to cover the additional commitment, + /// not if those available tokens are actually unallocated. + + mstore(0x00, 0x9f2aec67) // InvalidBalanceChange() + mstore(0x20, currentBalance) + mstore(0x40, diffBalance) + revert(0x1c, 0x44) + } + // Store the commitment let commitmentOffset := add(add(commitments, 0x20 /* skip length */ ), mul(i, 0x20)) let commitmentContent := @@ -390,7 +419,7 @@ library AllocatorLib { if (!ITheCompact(THE_COMPACT).isRegistered(recipient, claimHash, typehash)) { revert InvalidRegistration(recipient, claimHash, typehash); } - return (claimHash, commitments, storedNonce); + return (claimHash, commitments); } function checkCompactReentrancyGuardAndRevert() internal view { diff --git a/src/allocators/lib/ERC7683AllocatorLib.sol b/src/allocators/lib/ERC7683AllocatorLib.sol index f9f1219..ab98726 100644 --- a/src/allocators/lib/ERC7683AllocatorLib.sol +++ b/src/allocators/lib/ERC7683AllocatorLib.sol @@ -161,9 +161,7 @@ library ERC7683AllocatorLib { // Bounds check: s must be >= 0x20 (points after the first slot) and s + 0x20 within orderData.length // Also ensure no overflow on add(s, 0x20) - if or(lt(s, 0x20), gt(add(s, 0x20), orderData.length)) { - revert(0x00, 0x00) - } + if or(lt(s, 0x20), gt(add(s, 0x20), orderData.length)) { revert(0x00, 0x00) } // Compute pointer to nested Order (calldata pointer) order := add(orderData.offset, add(s, 0x20)) @@ -238,7 +236,7 @@ library ERC7683AllocatorLib { /// @return maxSpent The maximum spent Output array for the order. function createMaximumSpent(Fill memory mainFill, uint256 scalingFactor) internal - view + pure returns (IOriginSettler.Output[] memory) { uint256 amount = type(uint256).max; diff --git a/src/interfaces/IHybridAllocator.sol b/src/interfaces/IHybridAllocator.sol index b1124ee..6022666 100644 --- a/src/interfaces/IHybridAllocator.sol +++ b/src/interfaces/IHybridAllocator.sol @@ -10,6 +10,12 @@ import {ISignatureTransfer} from 'permit2/src/interfaces/ISignatureTransfer.sol' /// @notice Interface for hybrid allocators supporting both on-chain and off-chain authorization mechanisms /// @dev Combines direct token deposit functionality with signature-based off-chain allocation authorization interface IHybridAllocator is IOnChainAllocation { + struct HybridAllocationContext { + uint256 nonce; // MUST start with the off chain command, followed by the sponsors address + uint256[] additionalCommitmentAmounts; // MUST match the commitments lengths and order within the claim + bytes signature; + } + error InvalidAllocatorRegistration(address alreadyRegisteredAllocator); error Unsupported(); error InvalidIds(); @@ -88,6 +94,7 @@ interface IHybridAllocator is IOnChainAllocation { /// @param witness The witness typestring for the Permit2 signature (empty string if no witness) /// @param witnessHash The hash of the witness data (bytes32(0) if no witness) /// @param signature The Permit2 signature from the depositor, will be verified by the compact + /// @param context Additional context for the allocation /// @return commitments The lock commitments created by the allocation function permit2Allocation( address arbiter, @@ -98,6 +105,7 @@ interface IHybridAllocator is IOnChainAllocation { bytes32 claimHash, string calldata witness, bytes32 witnessHash, - bytes calldata signature + bytes calldata signature, + bytes calldata context ) external returns (Lock[] memory commitments); } diff --git a/src/interfaces/IOnChainAllocator.sol b/src/interfaces/IOnChainAllocator.sol index 71c6442..f1518b5 100644 --- a/src/interfaces/IOnChainAllocator.sol +++ b/src/interfaces/IOnChainAllocator.sol @@ -110,6 +110,7 @@ interface IOnChainAllocator is IOnChainAllocation { /// @param witness The witness typestring for the Permit2 signature (empty string if no witness) /// @param witnessHash The hash of the witness data (bytes32(0) if no witness) /// @param signature The Permit2 signature from the depositor, will be verified by the compact + /// @param context Additional context for the allocation /// @return commitments The lock commitments created by the allocation function permit2Allocation( address arbiter, @@ -120,6 +121,7 @@ interface IOnChainAllocator is IOnChainAllocation { bytes32 claimHash, string calldata witness, bytes32 witnessHash, - bytes calldata signature + bytes calldata signature, + bytes calldata context ) external returns (Lock[] memory commitments); } From cc01cbbbc3eea30dfc39aaab72b8033d78160845 Mon Sep 17 00:00:00 2001 From: mgretzke Date: Mon, 15 Dec 2025 13:34:39 +0100 Subject: [PATCH 2/4] additionalCommitmentAmounts mandatory in HybridAllocator --- lib/the-compact | 2 +- src/allocators/HybridAllocator.sol | 131 ++++++++++++++++------------ src/allocators/lib/AllocatorLib.sol | 96 +++++++++++++++----- src/interfaces/IHybridAllocator.sol | 1 - 4 files changed, 148 insertions(+), 82 deletions(-) diff --git a/lib/the-compact b/lib/the-compact index 4118ca9..0cc083d 160000 --- a/lib/the-compact +++ b/lib/the-compact @@ -1 +1 @@ -Subproject commit 4118ca99876b14b261116069e7d3c137a0751e11 +Subproject commit 0cc083d78aca921672f25f0f2090df5e3d12e1a4 diff --git a/src/allocators/HybridAllocator.sol b/src/allocators/HybridAllocator.sol index 220bda8..d54d19b 100644 --- a/src/allocators/HybridAllocator.sol +++ b/src/allocators/HybridAllocator.sol @@ -192,33 +192,51 @@ contract HybridAllocator is IHybridAllocator { return (claimHash, registeredAmounts, nonce); } - /// @inheritdoc IHybridAllocator + /// @inheritdoc IOnChainAllocation function permit2Allocation( address arbiter, address depositor, uint256 expires, ISignatureTransfer.TokenPermissions[] calldata permitted, + uint256[] calldata additionalCommitmentAmounts, DepositDetails calldata details, bytes32 claimHash, string calldata witness, bytes32 witnessHash, bytes calldata signature, - bytes calldata context - ) external returns (Lock[] memory commitments) { - commitments = AL.permit2Allocation( - arbiter, depositor, expires, permitted, details, claimHash, witness, witnessHash, signature + bytes calldata context // allocator signature + ) external returns (Lock[] memory) { + (Lock[] memory commitments, bool containsAdditionalCommitments) = AL.permit2Allocation( + arbiter, + depositor, + expires, + permitted, + additionalCommitmentAmounts, + details, + claimHash, + witness, + witnessHash, + signature ); + if (containsAdditionalCommitments) { + // Validate the allocator's signature for the additional commitments + _validateContext(commitments, details.nonce, additionalCommitmentAmounts, claimHash, context); + } + // Allocate the claim claims[claimHash] = true; emit Allocated(depositor, commitments, details.nonce, expires, claimHash); + + return commitments; } /// @inheritdoc IOnChainAllocation function prepareAllocation( address recipient, uint256[2][] calldata idsAndAmounts, + uint256[] calldata additionalCommitmentAmounts, address arbiter, uint256 expires, bytes32 typehash, @@ -227,68 +245,77 @@ contract HybridAllocator is IHybridAllocator { ) external returns (uint256 nonce) { if (context.length > 0) { // Potential off chain nonce provided - HybridAllocationContext calldata allocationContext = _decodeContext(idsAndAmounts.length, context); + HybridAllocationContext calldata allocationContext = _decodeContext(context); // Verify the nonce is scoped to an off chain allocation and to the recipient AL.verifyNonce(allocationContext.nonce, AL.OFF_CHAIN_NONCE, recipient); - - AL.prepareAllocation( - allocationContext.nonce, recipient, idsAndAmounts, arbiter, expires, typehash, witness, ALLOCATOR_ID - ); + nonce = allocationContext.nonce; } else { // No off chain nonce provided, use an on chain nonce uint88 nonce88 = nonces + 1; nonce = AL.getNonceWithCommand(AL.ON_CHAIN_NONCE, nonce88); - AL.prepareAllocation(nonce, recipient, idsAndAmounts, arbiter, expires, typehash, witness, ALLOCATOR_ID); } + AL.prepareAllocation( + nonce, + recipient, + idsAndAmounts, + additionalCommitmentAmounts, + arbiter, + expires, + typehash, + witness, + ALLOCATOR_ID + ); } - // TODO: THINK ABOUT IF THE uint256[] calldata additionalCommitmentAmounts should be a default parameter. A registration must confirm the users intentions and the contract must make sure that the additional commitments are unallocated. - // Either by checking an off chain signature, or on chain by checking the unallocated balances function executeAllocation( address recipient, uint256[2][] calldata idsAndAmounts, + uint256[] calldata additionalCommitmentAmounts, address arbiter, uint256 expires, bytes32 typehash, bytes32 witness, bytes calldata context ) external { + uint256 nonce; bytes32 claimHash; Lock[] memory commitments; - uint256 nonce; + bool containsAdditionalCommitments; if (context.length > 0) { // Off chain nonce and additional commitments amounts provided - HybridAllocationContext calldata allocationContext = _decodeContext(idsAndAmounts.length, context); + HybridAllocationContext calldata allocationContext = _decodeContext(context); // Verify the nonce is scoped to an off chain allocation and to the recipient AL.verifyNonce(allocationContext.nonce, AL.OFF_CHAIN_NONCE, recipient); - (claimHash, commitments) = AL.executeAllocation( - allocationContext.nonce, - recipient, - idsAndAmounts, - allocationContext.additionalCommitmentAmounts, - arbiter, - expires, - typehash, - witness + nonce = allocationContext.nonce; + (claimHash, commitments, containsAdditionalCommitments) = AL.executeAllocation( + nonce, recipient, idsAndAmounts, additionalCommitmentAmounts, arbiter, expires, typehash, witness ); - // Validate the signers signature for the hybrid allocation context - _validateContext(commitments, allocationContext, claimHash); + _validateContext( + commitments, + allocationContext.nonce, + additionalCommitmentAmounts, + claimHash, + allocationContext.signature + ); } else { // No off chain nonce provided, use an on chain nonce uint88 nonce88 = ++nonces; nonce = AL.getNonceWithCommand(AL.ON_CHAIN_NONCE, nonce88); - - (claimHash, commitments) = - AL.executeAllocation(nonce, recipient, idsAndAmounts, arbiter, expires, typehash, witness); + (claimHash, commitments, containsAdditionalCommitments) = AL.executeAllocation( + nonce, recipient, idsAndAmounts, additionalCommitmentAmounts, arbiter, expires, typehash, witness + ); + if (containsAdditionalCommitments) { + revert InvalidSignature(); + } } + // If the claim was already allocated, skip the allocation and the event emission if (claims[claimHash]) { - // If the claim was already allocated, skip the allocation and the event emission return; } @@ -425,7 +452,7 @@ contract HybridAllocator is IHybridAllocator { } } - function _decodeContext(uint256 commitmentsLength, bytes calldata context) + function _decodeContext(bytes calldata context) internal pure returns (HybridAllocationContext calldata allocationContext) @@ -433,29 +460,20 @@ contract HybridAllocator is IHybridAllocator { assembly ("memory-safe") { // HybridAllocationContext structure: // 0x00: HybridAllocationContext.offset - // 0x20: claimHash - // 0x40: nonce - // 0x60: additionalCoAm.offset - // 0x80: signature.offset - // 0xa0: additionalCoAm.length (must match commitments.length) - // 0xc0: additionalCommitments.content - // 0xc0 + (commitments.length * 0x20): signature.length (must be 64 or 65 bytes) - // 0xe0 + (commitments.length * 0x20): signature.content + // 0x20: nonce + // 0x40: signature.offset + // 0x60: signature.length + // 0x80: signature.content - // required length must be 0x100 + (commitments.length * 0x20) + signature length of 64 or 96 bytes (65 bytes will be padded to 96 bytes) + // required length must be 0x80 + signature length of 64 or 96 bytes (65 bytes will be padded to 96 bytes) - let requiredLength := 0x100 - let lengthOfCommitments := mul(0x20, commitmentsLength) - requiredLength := add(requiredLength, lengthOfCommitments) // must match commitments.length - requiredLength := add(requiredLength, 0x40) // compact signature of 64 bytes minimum length as minimum length + let minimumLength := 0xc0 - let errorBuffer := or(lt(context.length, requiredLength), gt(context.length, add(requiredLength, 0x20))) // check length of context is valid - errorBuffer := or(errorBuffer, xor(calldataload(add(context.offset, 0x60)), 0x80)) // check additionalCoAm offset is valid (offset relative to context.offset) - errorBuffer := or(errorBuffer, xor(calldataload(add(context.offset, 0xa0)), commitmentsLength)) // check additionalCoAm length is valid (must match commitments.length) - errorBuffer := or(errorBuffer, xor(calldataload(add(context.offset, 0x80)), add(0xa0, lengthOfCommitments))) // check signature offset is valid (offset relative to context.offset) + let errorBuffer := or(lt(context.length, minimumLength), gt(context.length, add(minimumLength, 0x20))) // check length of context is valid + errorBuffer := or(errorBuffer, xor(calldataload(add(context.offset, 0x40)), 0x60)) // check signature offset is valid (offset relative to context.offset) // Check the signature is valid - let calldataSignatureLength := calldataload(add(context.offset, add(0xc0, lengthOfCommitments))) + let calldataSignatureLength := calldataload(add(context.offset, 0x60)) errorBuffer := or(errorBuffer, or(lt(calldataSignatureLength, 0x40), gt(calldataSignatureLength, 0x41))) // check signature length is valid (must be 64 or 65 bytes) if errorBuffer { revert(0x00, 0x00) } @@ -465,12 +483,13 @@ contract HybridAllocator is IHybridAllocator { function _validateContext( Lock[] memory commitments, - HybridAllocationContext calldata allocationContext, - bytes32 claimHash + uint256 nonce, + uint256[] calldata additionalCommitmentAmounts, + bytes32 claimHash, + bytes calldata allocatorSignature ) internal view { bytes32 hybridAllocationHash; bytes32[] memory commitmentsHashes = new bytes32[](commitments.length); - uint256[] calldata additionalCommitmentAmounts = allocationContext.additionalCommitmentAmounts; // Create the hybrid allocation context hash assembly ("memory-safe") { @@ -483,10 +502,10 @@ contract HybridAllocator is IHybridAllocator { let m := mload(0x40) mstore(m, HYBRID_ALLOCATION_CONTEXT_TYPEHASH) // typehash mstore(add(m, 0x20), claimHash) // claimHash - mstore(add(m, 0x40), calldataload(allocationContext)) // nonce + mstore(add(m, 0x40), nonce) // nonce // Create the commitments hash - // Use the commitments lockTag and token, but the amount from HybridAllocationContext.additionalCommitmentAmounts + // Use the commitments lockTag and token, but the amount from additionalCommitmentAmounts let freeMemoryPointer := add(m, 0x80) let commitmentsLength := mload(commitments) // Populate all thecommitmentHashes @@ -497,7 +516,7 @@ contract HybridAllocator is IHybridAllocator { mstore(add(freeMemoryPointer, 0x40), mload(add(commitmentOffset, 0x20))) // token from commitments mstore( add(freeMemoryPointer, 0x60), calldataload(add(additionalCommitmentAmounts.offset, mul(i, 0x20))) - ) // amount from HybridAllocationContext.additionalCommitmentAmounts + ) // amount from additionalCommitmentAmounts let commitmentsHashPointer := add(add(commitmentsHashes, 0x20 /* skip length */ ), mul(i, 0x20)) mstore(commitmentsHashPointer, keccak256(freeMemoryPointer, 0x80)) } @@ -514,7 +533,7 @@ contract HybridAllocator is IHybridAllocator { // If the chain was forked, we can not use the cached domain separator digest = _deriveDigest(claimHash, ITheCompact(AL.THE_COMPACT).DOMAIN_SEPARATOR()); } - if (!_checkSignature(digest, allocationContext.signature)) { + if (!_checkSignature(digest, allocatorSignature)) { revert InvalidSignature(); } } diff --git a/src/allocators/lib/AllocatorLib.sol b/src/allocators/lib/AllocatorLib.sol index 69d559a..70a1afd 100644 --- a/src/allocators/lib/AllocatorLib.sol +++ b/src/allocators/lib/AllocatorLib.sol @@ -56,6 +56,8 @@ library AllocatorLib { bytes1 internal constant NONCE_COMMAND_MASK = 0xff; + error InvalidBalanceForAdditionalCommitments(uint256 availableBalance, uint256 expectedBalance); + error InvalidAdditionalCommitmentsLength(uint256 providedLength, uint256 expectedLength); error InvalidBalanceChange(uint256 newBalance, uint256 oldBalance); error InvalidPreparation(); error InvalidAllocatorId(uint96 providedId, uint96 allocatorId); @@ -71,12 +73,16 @@ library AllocatorLib { address depositor, uint256 expires, ISignatureTransfer.TokenPermissions[] calldata permitted, + uint256[] calldata additionalCommitmentAmounts, DepositDetails calldata details, bytes32 claimHash, // This claim hash is connected to the allocation. This does not guarantee, that the allocated tokens are connected to the claim hash. string calldata witness, bytes32 witnessHash, bytes calldata signature - ) internal returns (Lock[] memory commitments) { + ) internal returns (Lock[] memory commitments, bool containsAdditionalCommitments) { + // Ensure the additional commitment amounts are the correct length + checkAdditionalCommitmentsAndRevert(permitted.length, additionalCommitmentAmounts); + // Verifying the nonce is scoped to a permit2 allocation and to the sponsor verifyNonce(details.nonce, PERMIT2_NONCE, depositor); // We can now trust permit2 to burn the nonce and prevent replay attacks @@ -183,6 +189,25 @@ library AllocatorLib { } let diffBalance := sub(currentBalance, oldBalance) + let additionalCommitmentAmount := calldataload(add(additionalCommitmentAmounts.offset, mul(i, 0x20))) + if gt(additionalCommitmentAmount, oldBalance) { + /// @dev This is NOT a sufficient check to guarantee the user has enough unallocated tokens available for the additional commitment. + /// The additional committed amounts MUST be verified by the implementation of the allocator library before or after this function is called. + /// This check will only be used to check if enough tokens are generally available to cover the additional commitment, + /// not if those available tokens are actually unallocated. + + mstore(0x00, 0x9a534c61) // InvalidBalanceForAdditionalCommitments() + mstore(0x20, oldBalance) + mstore(0x40, additionalCommitmentAmount) + revert(0x1c, 0x44) + } + + // Add the additional commitment amount to the difference in balance. This amount must be verifiably unallocated. + diffBalance := add(diffBalance, additionalCommitmentAmount) + + // Set the containsAdditionalCommitments flag if the additional commitment amount is not zero + containsAdditionalCommitments := or(containsAdditionalCommitments, gt(additionalCommitmentAmount, 0)) + // Update the amount in the Lock struct with the balance difference mstore(commitmentAmountMemLoc, diffBalance) } @@ -206,19 +231,22 @@ library AllocatorLib { revert InvalidClaim(claimHash); } - return commitments; + return (commitments, containsAdditionalCommitments); } function prepareAllocation( uint256 nonce, address recipient, uint256[2][] calldata idsAndAmounts, + uint256[] calldata additionalCommitmentAmounts, address arbiter, uint256 expires, bytes32 typehash, bytes32 witness, uint96 allocatorId ) internal { + // Ensure the additional commitment amounts are the correct length + checkAdditionalCommitmentsAndRevert(idsAndAmounts.length, additionalCommitmentAmounts); // Before preparing the allocation, check if the compact's reentrancy guard is active checkCompactReentrancyGuardAndRevert(); @@ -258,6 +286,21 @@ library AllocatorLib { staticcall(gas(), THE_COMPACT, 0x10, 0x44, 0x20, 0x20) ) ) + + // Verify the current balance is sufficient for the additional commitment amounts + let additionalCommitmentAmount := calldataload(add(additionalCommitmentAmounts.offset, mul(i, 0x20))) + if gt(additionalCommitmentAmount, currentBalance) { + /// @dev This is NOT a sufficient check to guarantee the user has enough unallocated tokens available for the additional commitment. + /// The additional committed amounts MUST be verified by the implementation of the allocator library during the execution. + /// This check will only be used to check if enough tokens are generally available to cover the additional commitment, + /// not if those available tokens are actually unallocated. + + mstore(0x00, 0x9a534c61) // InvalidBalanceForAdditionalCommitments() + mstore(0x20, currentBalance) + mstore(0x40, additionalCommitmentAmount) + revert(0x1c, 0x44) + } + mstore(0x00, PREPARE_ALLOCATION_SELECTOR) mstore(0x20, recipient) mstore(0x40, id) @@ -278,22 +321,6 @@ library AllocatorLib { } } - function executeAllocation( - uint256 nonce, - address recipient, - uint256[2][] calldata idsAndAmounts, - address arbiter, - uint256 expires, - bytes32 typehash, - bytes32 witness - ) internal view returns (bytes32 claimHash, Lock[] memory) { - uint256[] memory additionalCommitmentAmounts = new uint256[](idsAndAmounts.length); - - return executeAllocation( - nonce, recipient, idsAndAmounts, additionalCommitmentAmounts, arbiter, expires, typehash, witness - ); - } - /// @dev Additional commitment amounts MUST be unallocated, which IS NOT verified by this library. function executeAllocation( uint256 nonce, @@ -304,12 +331,15 @@ library AllocatorLib { uint256 expires, bytes32 typehash, bytes32 witness - ) internal view returns (bytes32 claimHash, Lock[] memory) { + ) internal view returns (bytes32 claimHash, Lock[] memory, bool containsAdditionalCommitments) { bytes32[] memory commitmentHashes = new bytes32[](idsAndAmounts.length); Lock[] memory commitments = new Lock[](idsAndAmounts.length); bytes32 commitmentsHash; uint256 storedNonce; + // Ensure the additional commitment amounts are the correct length + checkAdditionalCommitmentsAndRevert(idsAndAmounts.length, additionalCommitmentAmounts); + // Before executing the allocation, check if the compact's reentrancy guard is active checkCompactReentrancyGuardAndRevert(); @@ -359,20 +389,23 @@ library AllocatorLib { } let diffBalance := sub(currentBalance, oldBalance) - // Add the additional commitment amount. let additionalCommitmentAmount := calldataload(add(additionalCommitmentAmounts.offset, mul(i, 0x20))) - diffBalance := add(diffBalance, additionalCommitmentAmount) - if gt(diffBalance, currentBalance) { + if gt(additionalCommitmentAmount, oldBalance) { /// @dev This is NOT a sufficient check to guarantee the user has enough unallocated tokens available for the additional commitment. /// The additional committed amounts MUST be verified by the implementation of the allocator library. /// This check will only be used to check if enough tokens are generally available to cover the additional commitment, /// not if those available tokens are actually unallocated. - mstore(0x00, 0x9f2aec67) // InvalidBalanceChange() + mstore(0x00, 0x9a534c61) // InvalidBalanceForAdditionalCommitments() mstore(0x20, currentBalance) mstore(0x40, diffBalance) revert(0x1c, 0x44) } + // Add the additional commitment amount. + diffBalance := add(diffBalance, additionalCommitmentAmount) + + // Set the containsAdditionalCommitments flag if the additional commitment amount is not zero + containsAdditionalCommitments := or(containsAdditionalCommitments, gt(additionalCommitmentAmount, 0)) // Store the commitment let commitmentOffset := add(add(commitments, 0x20 /* skip length */ ), mul(i, 0x20)) @@ -419,7 +452,7 @@ library AllocatorLib { if (!ITheCompact(THE_COMPACT).isRegistered(recipient, claimHash, typehash)) { revert InvalidRegistration(recipient, claimHash, typehash); } - return (claimHash, commitments); + return (claimHash, commitments, containsAdditionalCommitments); } function checkCompactReentrancyGuardAndRevert() internal view { @@ -448,6 +481,21 @@ library AllocatorLib { } } + function checkAdditionalCommitmentsAndRevert(uint256 target, uint256[] calldata additionalCommitmentAmounts) + private + view + { + assembly ("memory-safe") { + let additionalCommitmentAmountsLength := additionalCommitmentAmounts.length + if iszero(eq(target, additionalCommitmentAmountsLength)) { + mstore(0x00, 0x81c2fd0e) // InvalidAdditionalCommitmentsLength() + mstore(0x20, additionalCommitmentAmountsLength) + mstore(0x40, target) + revert(0x1c, 0x44) + } + } + } + function getRegisteredAllocator(uint96 allocatorId) internal view returns (address allocator) { assembly ("memory-safe") { mstore(0x00, EXTSLOAD_SELECTOR) diff --git a/src/interfaces/IHybridAllocator.sol b/src/interfaces/IHybridAllocator.sol index 6022666..6cc9b0c 100644 --- a/src/interfaces/IHybridAllocator.sol +++ b/src/interfaces/IHybridAllocator.sol @@ -12,7 +12,6 @@ import {ISignatureTransfer} from 'permit2/src/interfaces/ISignatureTransfer.sol' interface IHybridAllocator is IOnChainAllocation { struct HybridAllocationContext { uint256 nonce; // MUST start with the off chain command, followed by the sponsors address - uint256[] additionalCommitmentAmounts; // MUST match the commitments lengths and order within the claim bytes signature; } From abef2166cd9acf4ce1eb88386b906bc5247fe783 Mon Sep 17 00:00:00 2001 From: mgretzke Date: Tue, 16 Dec 2025 11:51:53 +0100 Subject: [PATCH 3/4] additionalCommitmentAmounts mandatory in OnChainAllocator + fixes --- lib/the-compact | 2 +- src/allocators/HybridAllocator.sol | 31 ++---- src/allocators/OnChainAllocator.sol | 149 ++++++++++++++++++++------- src/allocators/lib/AllocatorLib.sol | 39 +++++-- src/interfaces/IHybridAllocator.sol | 27 ----- src/interfaces/IOnChainAllocator.sol | 27 ----- 6 files changed, 158 insertions(+), 117 deletions(-) diff --git a/lib/the-compact b/lib/the-compact index 0cc083d..65db4de 160000 --- a/lib/the-compact +++ b/lib/the-compact @@ -1 +1 @@ -Subproject commit 0cc083d78aca921672f25f0f2090df5e3d12e1a4 +Subproject commit 65db4de480460f934a4fc0d7e9e9a06137e3c7c3 diff --git a/src/allocators/HybridAllocator.sol b/src/allocators/HybridAllocator.sol index d54d19b..cc0e186 100644 --- a/src/allocators/HybridAllocator.sol +++ b/src/allocators/HybridAllocator.sol @@ -30,9 +30,9 @@ contract HybridAllocator is IHybridAllocator { event AllocatorInitialized(address compact, address owner, uint96 allocatorId); /// @dev The typehash for the HybridAllocationContext: - /// keccak256('HybridAllocationContext(bytes32 claimHash,uint256 nonce,Lock[] additionalCommitments)Lock(bytes12 lockTag,address token,uint256 amount)') + /// keccak256('HybridAllocationContext(bytes32 claimHash,Lock[] additionalCommitments)Lock(bytes12 lockTag,address token,uint256 amount)') bytes32 constant HYBRID_ALLOCATION_CONTEXT_TYPEHASH = - 0x6aa08e172ed524dc2013404e602ede105a3158471f628286084d00f175913ee4; + 0x3d88798eb330fca0ab1589827743878b2ccd0cdaa353080dffeb0d3e6fd7a639; /// @notice The unique identifier for this allocator within The Compact protocol uint96 public immutable ALLOCATOR_ID; @@ -206,7 +206,7 @@ contract HybridAllocator is IHybridAllocator { bytes calldata signature, bytes calldata context // allocator signature ) external returns (Lock[] memory) { - (Lock[] memory commitments, bool containsAdditionalCommitments) = AL.permit2Allocation( + (Lock[] memory commitments,, bool containsAdditionalCommitments) = AL.permit2Allocation( arbiter, depositor, expires, @@ -221,7 +221,7 @@ contract HybridAllocator is IHybridAllocator { if (containsAdditionalCommitments) { // Validate the allocator's signature for the additional commitments - _validateContext(commitments, details.nonce, additionalCommitmentAmounts, claimHash, context); + _validateContext(commitments, additionalCommitmentAmounts, claimHash, context); } // Allocate the claim @@ -291,22 +291,16 @@ contract HybridAllocator is IHybridAllocator { AL.verifyNonce(allocationContext.nonce, AL.OFF_CHAIN_NONCE, recipient); nonce = allocationContext.nonce; - (claimHash, commitments, containsAdditionalCommitments) = AL.executeAllocation( + (claimHash, commitments,, containsAdditionalCommitments) = AL.executeAllocation( nonce, recipient, idsAndAmounts, additionalCommitmentAmounts, arbiter, expires, typehash, witness ); // Validate the signers signature for the hybrid allocation context - _validateContext( - commitments, - allocationContext.nonce, - additionalCommitmentAmounts, - claimHash, - allocationContext.signature - ); + _validateContext(commitments, additionalCommitmentAmounts, claimHash, allocationContext.signature); } else { // No off chain nonce provided, use an on chain nonce uint88 nonce88 = ++nonces; nonce = AL.getNonceWithCommand(AL.ON_CHAIN_NONCE, nonce88); - (claimHash, commitments, containsAdditionalCommitments) = AL.executeAllocation( + (claimHash, commitments,, containsAdditionalCommitments) = AL.executeAllocation( nonce, recipient, idsAndAmounts, additionalCommitmentAmounts, arbiter, expires, typehash, witness ); if (containsAdditionalCommitments) { @@ -483,7 +477,6 @@ contract HybridAllocator is IHybridAllocator { function _validateContext( Lock[] memory commitments, - uint256 nonce, uint256[] calldata additionalCommitmentAmounts, bytes32 claimHash, bytes calldata allocatorSignature @@ -496,17 +489,15 @@ contract HybridAllocator is IHybridAllocator { // hybrid allocation context hash: // 0x00: typehash // 0x20: claimHash - // 0x40: nonce - // 0x60: additionalCommitments hash + // 0x40: additionalCommitments hash let m := mload(0x40) mstore(m, HYBRID_ALLOCATION_CONTEXT_TYPEHASH) // typehash mstore(add(m, 0x20), claimHash) // claimHash - mstore(add(m, 0x40), nonce) // nonce // Create the commitments hash // Use the commitments lockTag and token, but the amount from additionalCommitmentAmounts - let freeMemoryPointer := add(m, 0x80) + let freeMemoryPointer := add(m, 0x60) let commitmentsLength := mload(commitments) // Populate all thecommitmentHashes mstore(freeMemoryPointer, LOCK_TYPEHASH) @@ -523,10 +514,10 @@ contract HybridAllocator is IHybridAllocator { // Create the commitments hash: keccak256(abi.encodePacked(commitmentsHashes)) mstore( - add(m, 0x60), keccak256(add(commitmentsHashes, 0x20 /* skip length */ ), mul(commitmentsLength, 0x20)) + add(m, 0x40), keccak256(add(commitmentsHashes, 0x20 /* skip length */ ), mul(commitmentsLength, 0x20)) ) - hybridAllocationHash := keccak256(m, 0x80) + hybridAllocationHash := keccak256(m, 0x60) } bytes32 digest = _deriveDigest(hybridAllocationHash, _COMPACT_DOMAIN_SEPARATOR); if (block.chainid != _INITIAL_CHAIN_ID) { diff --git a/src/allocators/OnChainAllocator.sol b/src/allocators/OnChainAllocator.sol index b0a7d18..9df8c52 100644 --- a/src/allocators/OnChainAllocator.sol +++ b/src/allocators/OnChainAllocator.sol @@ -161,7 +161,9 @@ contract OnChainAllocator is IOnChainAllocator, Utility { revert InvalidAmount(commitments[i].amount); } - minResetPeriod = _checkInput(commitments[i], recipient, expires, minResetPeriod); + minResetPeriod = _checkInput( + commitments[i].lockTag, commitments[i].token, commitments[i].amount, recipient, expires, minResetPeriod + ); idsAndAmounts[i][0] = AL.toId(commitments[i].lockTag, commitments[i].token); idsAndAmounts[i][1] = msg.value; @@ -178,7 +180,9 @@ contract OnChainAllocator is IOnChainAllocator, Utility { // Process the rest of the commitments for (; i < commitments.length; i++) { - minResetPeriod = _checkInput(commitments[i], recipient, expires, minResetPeriod); + minResetPeriod = _checkInput( + commitments[i].lockTag, commitments[i].token, commitments[i].amount, recipient, expires, minResetPeriod + ); address token = commitments[i].token; // Safe to cast - _checkInput validated that the value fits the uint224 @@ -243,56 +247,89 @@ contract OnChainAllocator is IOnChainAllocator, Utility { return commitments; } - /// @inheritdoc IOnChainAllocator + /// @inheritdoc IOnChainAllocation function permit2Allocation( address arbiter, address depositor, uint256 expires, ISignatureTransfer.TokenPermissions[] calldata permitted, + uint256[] calldata additionalCommitmentAmounts, DepositDetails calldata details, bytes32 claimHash, string calldata witness, bytes32 witnessHash, bytes calldata signature, bytes calldata /* context */ - ) external returns (Lock[] memory commitments) { + ) external returns (Lock[] memory) { if (expires > type(uint32).max) { revert InvalidExpiration(expires, type(uint32).max); } - commitments = AL.permit2Allocation( - arbiter, depositor, expires, permitted, details, claimHash, witness, witnessHash, signature + (Lock[] memory commitments, uint256[] memory previousBalances, bool containsAdditionalCommitments) = AL + .permit2Allocation( + arbiter, + depositor, + expires, + permitted, + additionalCommitmentAmounts, + details, + claimHash, + witness, + witnessHash, + signature ); + uint256 minResetPeriod = type(uint256).max; + // Allocate the claim for (uint256 i = 0; i < commitments.length; i++) { - // Check the amount fits in the supported range - if (commitments[i].amount > type(uint224).max) { - revert InvalidAmount(commitments[i].amount); + Lock memory commitment = commitments[i]; + minResetPeriod = _checkInput( + commitment.lockTag, commitment.token, commitment.amount, depositor, uint32(expires), minResetPeriod + ); + + if (containsAdditionalCommitments) { + // Check the unallocated balance of the recipient is sufficient for the additionalCommitmentAmounts + bytes32 tokenHash = _getTokenHash(commitment.lockTag, commitment.token, depositor); + uint256 allocatedBalance = _allocatedBalance(tokenHash); + uint256 requiredBalance = allocatedBalance + additionalCommitmentAmounts[i]; + if (previousBalances[i] < requiredBalance) { + revert InsufficientBalance( + depositor, AL.toId(commitment.lockTag, commitment.token), previousBalances[i], requiredBalance + ); + } } _storeAllocation( - commitments[i].lockTag, - commitments[i].token, - uint224(commitments[i].amount), + commitment.lockTag, + commitment.token, + uint224(commitment.amount), depositor, uint32(expires), // expires is verified in the AllocatorLib.permit2Allocation function claimHash ); } + // Ensure expiration is not bigger then the smallest reset period + if (expires >= block.timestamp + minResetPeriod) { + revert InvalidExpiration(expires, block.timestamp + minResetPeriod); + } + emit Allocated(depositor, commitments, details.nonce, expires, claimHash); + + return commitments; } /// @inheritdoc IOnChainAllocation function prepareAllocation( address recipient, uint256[2][] calldata idsAndAmounts, + uint256[] calldata additionalCommitmentAmounts, address arbiter, uint256 expires, bytes32 typehash, bytes32 witness, - bytes calldata /* orderData */ + bytes calldata /* context */ ) external returns (uint256 nonce) { if (expires > type(uint32).max) { revert InvalidExpiration(expires, type(uint32).max); @@ -300,7 +337,15 @@ contract OnChainAllocator is IOnChainAllocator, Utility { uint32 expiration = uint32(expires); nonce = _getNonce(msg.sender, recipient); // Includes command. AL will handle the command, so we remove it by casting to uint248. AL.prepareAllocation( - uint248(nonce), recipient, idsAndAmounts, arbiter, expiration, typehash, witness, ALLOCATOR_ID + uint248(nonce), + recipient, + idsAndAmounts, + additionalCommitmentAmounts, + arbiter, + expiration, + typehash, + witness, + ALLOCATOR_ID ); return nonce; @@ -310,11 +355,12 @@ contract OnChainAllocator is IOnChainAllocator, Utility { function executeAllocation( address recipient, uint256[2][] calldata idsAndAmounts, + uint256[] calldata additionalCommitmentAmounts, address arbiter, uint256 expires, bytes32 typehash, bytes32 witness, - bytes calldata /* orderData */ + bytes calldata /* context */ ) external { if (expires > type(uint32).max) { revert InvalidExpiration(expires, type(uint32).max); @@ -322,8 +368,9 @@ contract OnChainAllocator is IOnChainAllocator, Utility { uint32 expiration = uint32(expires); uint256 nonce = _getAndUpdateNonce(msg.sender, recipient); // Includes command. AL will handle the command, so we remove it by casting to uint248. - (bytes32 claimHash, Lock[] memory commitments) = - _executeAllocation(nonce, recipient, idsAndAmounts, arbiter, expiration, typehash, witness); + (bytes32 claimHash, Lock[] memory commitments) = _executeAllocation( + nonce, recipient, idsAndAmounts, additionalCommitmentAmounts, arbiter, expiration, typehash, witness + ); emit Allocated(recipient, commitments, nonce, expiration, claimHash); } @@ -332,19 +379,40 @@ contract OnChainAllocator is IOnChainAllocator, Utility { uint256 nonce, address recipient, uint256[2][] calldata idsAndAmounts, + uint256[] calldata additionalCommitmentAmounts, address arbiter, uint32 expires, bytes32 typehash, bytes32 witness ) private returns (bytes32, Lock[] memory) { - (bytes32 claimHash, Lock[] memory commitments,) = - AL.executeAllocation(uint248(nonce), recipient, idsAndAmounts, arbiter, expires, typehash, witness); + ( + bytes32 claimHash, + Lock[] memory commitments, + uint256[] memory previousBalances, + bool containsAdditionalCommitments + ) = AL.executeAllocation( + uint248(nonce), recipient, idsAndAmounts, additionalCommitmentAmounts, arbiter, expires, typehash, witness + ); + + uint256 minResetPeriod = type(uint256).max; // Allocate the claim for (uint256 i = 0; i < commitments.length; i++) { - // Check the amount fits in the supported range - if (commitments[i].amount > type(uint224).max) { - revert InvalidAmount(commitments[i].amount); + Lock memory commitment = commitments[i]; + + minResetPeriod = + _checkInput(commitment.lockTag, commitment.token, commitment.amount, recipient, expires, minResetPeriod); + + if (containsAdditionalCommitments) { + // Check the unallocated balance of the recipient is sufficient for the additionalCommitmentAmounts + bytes32 tokenHash = _getTokenHash(commitment.lockTag, commitment.token, recipient); + uint256 allocatedBalance = _allocatedBalance(tokenHash); + uint256 requiredBalance = allocatedBalance + additionalCommitmentAmounts[i]; + if (previousBalances[i] < requiredBalance) { + revert InsufficientBalance( + recipient, AL.toId(commitment.lockTag, commitment.token), previousBalances[i], requiredBalance + ); + } } _storeAllocation( @@ -357,6 +425,11 @@ contract OnChainAllocator is IOnChainAllocator, Utility { ); } + // Ensure expiration is not bigger then the smallest reset period + if (expires >= block.timestamp + minResetPeriod) { + revert InvalidExpiration(expires, block.timestamp + minResetPeriod); + } + return (claimHash, commitments); } @@ -454,7 +527,9 @@ contract OnChainAllocator is IOnChainAllocator, Utility { uint256 minResetPeriod = type(uint256).max; for (uint256 i = 0; i < commitments.length; i++) { - minResetPeriod = _checkInput(commitments[i], sponsor, expires, minResetPeriod); + minResetPeriod = _checkInput( + commitments[i].lockTag, commitments[i].token, commitments[i].amount, sponsor, expires, minResetPeriod + ); bytes32 tokenHash = _checkBalance(sponsor, commitments[i]); // Store the allocation @@ -469,31 +544,33 @@ contract OnChainAllocator is IOnChainAllocator, Utility { return (claimHash, nonce); } - function _checkInput(Lock calldata commitment, address sponsor, uint32 expires, uint256 minResetPeriod) - internal - view - returns (uint256) - { + function _checkInput( + bytes12 lockTag, + address token, + uint256 amount, + address sponsor, + uint32 expires, + uint256 minResetPeriod + ) internal view returns (uint256) { // Check the allocator id fits this allocator - if (AL.splitAllocatorId(commitment.lockTag) != ALLOCATOR_ID) { - revert InvalidAllocator(AL.splitAllocatorId(commitment.lockTag), ALLOCATOR_ID); + if (AL.splitAllocatorId(lockTag) != ALLOCATOR_ID) { + revert InvalidAllocator(AL.splitAllocatorId(lockTag), ALLOCATOR_ID); } // Check the amount fits in the supported range - if (commitment.amount > type(uint224).max) { - revert InvalidAmount(commitment.amount); + if (amount > type(uint224).max) { + revert InvalidAmount(amount); } // Get the reset period for the token id - uint256 duration = AL.toSeconds(commitment.lockTag); + uint256 duration = AL.toSeconds(lockTag); if (duration < minResetPeriod) { minResetPeriod = duration; } // Ensure no forcedWithdrawal is active for the token id - (, uint256 forcedWithdrawal) = ITheCompact(AL.THE_COMPACT).getForcedWithdrawalStatus( - sponsor, AL.toId(commitment.lockTag, commitment.token) - ); + (, uint256 forcedWithdrawal) = + ITheCompact(AL.THE_COMPACT).getForcedWithdrawalStatus(sponsor, AL.toId(lockTag, token)); if (forcedWithdrawal != 0 && forcedWithdrawal <= expires) { revert ForceWithdrawalAvailable(expires, forcedWithdrawal); } diff --git a/src/allocators/lib/AllocatorLib.sol b/src/allocators/lib/AllocatorLib.sol index 70a1afd..ac5c065 100644 --- a/src/allocators/lib/AllocatorLib.sol +++ b/src/allocators/lib/AllocatorLib.sol @@ -79,7 +79,10 @@ library AllocatorLib { string calldata witness, bytes32 witnessHash, bytes calldata signature - ) internal returns (Lock[] memory commitments, bool containsAdditionalCommitments) { + ) + internal + returns (Lock[] memory commitments, uint256[] memory previousBalances, bool containsAdditionalCommitments) + { // Ensure the additional commitment amounts are the correct length checkAdditionalCommitmentsAndRevert(permitted.length, additionalCommitmentAmounts); @@ -88,6 +91,8 @@ library AllocatorLib { // We can now trust permit2 to burn the nonce and prevent replay attacks commitments = new Lock[](permitted.length); + previousBalances = new uint256[](permitted.length); + bytes12 lockTag = details.lockTag; // Prepare allocation @@ -161,6 +166,8 @@ library AllocatorLib { revert(0x1c, 0x24) } + let previousBalancesPointer := add(previousBalances, 0x20) + // Confirm the allocation - calculate balance differences for { let i := 0 } lt(i, permittedLength) { i := add(i, 1) } { let commitmentMemLoc := mload(add(commitmentsContent, mul(i, 0x20))) // load the absolute pointer to the Lock struct @@ -202,6 +209,9 @@ library AllocatorLib { revert(0x1c, 0x44) } + // Store the old balance in the previousBalances array + mstore(add(previousBalancesPointer, mul(i, 0x20)), oldBalance) + // Add the additional commitment amount to the difference in balance. This amount must be verifiably unallocated. diffBalance := add(diffBalance, additionalCommitmentAmount) @@ -231,7 +241,7 @@ library AllocatorLib { revert InvalidClaim(claimHash); } - return (commitments, containsAdditionalCommitments); + return (commitments, previousBalances, containsAdditionalCommitments); } function prepareAllocation( @@ -331,9 +341,20 @@ library AllocatorLib { uint256 expires, bytes32 typehash, bytes32 witness - ) internal view returns (bytes32 claimHash, Lock[] memory, bool containsAdditionalCommitments) { + ) + internal + view + returns ( + bytes32 claimHash, + Lock[] memory commitments, + uint256[] memory previousBalances, + bool containsAdditionalCommitments + ) + { + commitments = new Lock[](idsAndAmounts.length); + previousBalances = new uint256[](idsAndAmounts.length); + bytes32[] memory commitmentHashes = new bytes32[](idsAndAmounts.length); - Lock[] memory commitments = new Lock[](idsAndAmounts.length); bytes32 commitmentsHash; uint256 storedNonce; @@ -359,6 +380,8 @@ library AllocatorLib { let freeSlots := add(add(memoryPointer, 0x100), mul(idsAndAmounts.length, 0x20)) mstore(freeSlots, LOCK_TYPEHASH) // Store the typehash for the commitment hash creation + let previousBalancesPointer := add(previousBalances, 0x20) + for { let i := 0 } lt(i, idsAndAmounts.length) { i := add(i, 1) } { let id := calldataload(add(idsAndAmounts.offset, mul(i, 0x40))) // store the id for the identifier creation @@ -401,6 +424,10 @@ library AllocatorLib { mstore(0x40, diffBalance) revert(0x1c, 0x44) } + + // Store the old balance in the previousBalances array + mstore(add(previousBalancesPointer, mul(i, 0x20)), oldBalance) + // Add the additional commitment amount. diffBalance := add(diffBalance, additionalCommitmentAmount) @@ -452,7 +479,7 @@ library AllocatorLib { if (!ITheCompact(THE_COMPACT).isRegistered(recipient, claimHash, typehash)) { revert InvalidRegistration(recipient, claimHash, typehash); } - return (claimHash, commitments, containsAdditionalCommitments); + return (claimHash, commitments, previousBalances, containsAdditionalCommitments); } function checkCompactReentrancyGuardAndRevert() internal view { @@ -483,7 +510,7 @@ library AllocatorLib { function checkAdditionalCommitmentsAndRevert(uint256 target, uint256[] calldata additionalCommitmentAmounts) private - view + pure { assembly ("memory-safe") { let additionalCommitmentAmountsLength := additionalCommitmentAmounts.length diff --git a/src/interfaces/IHybridAllocator.sol b/src/interfaces/IHybridAllocator.sol index 6cc9b0c..03e3d68 100644 --- a/src/interfaces/IHybridAllocator.sol +++ b/src/interfaces/IHybridAllocator.sol @@ -80,31 +80,4 @@ interface IHybridAllocator is IOnChainAllocation { bytes32 typehash, bytes32 witness ) external payable returns (bytes32, uint256[] memory, uint256); - - /// @notice Deposits, registers and allocates a claim via Permit2 signature transfer - /// @dev Deposits the tokens subject to the order and registers the claim directly with the compact, then allocates the claim - /// @param arbiter The arbiter of the allocation - /// @param depositor The address depositing tokens and the sponsor of the claim (must sign the Permit2 message) - /// @param permitted The token permissions for the Permit2 transfer. Must match the commitments in the claim - /// @param details The deposit details including nonce, deadline, and lock tag - /// Nonce must match the nonce structure expected by the allocator - /// Deadline will be used as the expiration of the claim - /// @param claimHash The hash of the claim to register. Must match the claim hash recreated by the allocator - /// @param witness The witness typestring for the Permit2 signature (empty string if no witness) - /// @param witnessHash The hash of the witness data (bytes32(0) if no witness) - /// @param signature The Permit2 signature from the depositor, will be verified by the compact - /// @param context Additional context for the allocation - /// @return commitments The lock commitments created by the allocation - function permit2Allocation( - address arbiter, - address depositor, - uint256 expires, - ISignatureTransfer.TokenPermissions[] calldata permitted, - DepositDetails calldata details, - bytes32 claimHash, - string calldata witness, - bytes32 witnessHash, - bytes calldata signature, - bytes calldata context - ) external returns (Lock[] memory commitments); } diff --git a/src/interfaces/IOnChainAllocator.sol b/src/interfaces/IOnChainAllocator.sol index f1518b5..cb3caa9 100644 --- a/src/interfaces/IOnChainAllocator.sol +++ b/src/interfaces/IOnChainAllocator.sol @@ -97,31 +97,4 @@ interface IOnChainAllocator is IOnChainAllocation { bytes32 typehash, bytes32 witness ) external payable returns (bytes32 claimHash, uint256[] memory registeredAmounts, uint256 nonce); - - /// @notice Deposits, registers and allocates a claim via Permit2 signature transfer - /// @dev Deposits the tokens subject to the order and registers the claim directly with the compact, then allocates the claim - /// @param arbiter The arbiter of the allocation - /// @param depositor The address depositing tokens and the sponsor of the claim (must sign the Permit2 message) - /// @param permitted The token permissions for the Permit2 transfer. Must match the commitments in the claim - /// @param details The deposit details including nonce, deadline, and lock tag - /// Nonce must match the nonce structure expected by the allocator - /// Deadline will be used as the expiration of the claim - /// @param claimHash The hash of the claim to register. Must match the claim hash recreated by the allocator - /// @param witness The witness typestring for the Permit2 signature (empty string if no witness) - /// @param witnessHash The hash of the witness data (bytes32(0) if no witness) - /// @param signature The Permit2 signature from the depositor, will be verified by the compact - /// @param context Additional context for the allocation - /// @return commitments The lock commitments created by the allocation - function permit2Allocation( - address arbiter, - address depositor, - uint256 expires, - ISignatureTransfer.TokenPermissions[] calldata permitted, - DepositDetails calldata details, - bytes32 claimHash, - string calldata witness, - bytes32 witnessHash, - bytes calldata signature, - bytes calldata context - ) external returns (Lock[] memory commitments); } From e7bd57fd3eab2f31ece67526881777520431d1de Mon Sep 17 00:00:00 2001 From: mgretzke Date: Tue, 16 Dec 2025 16:18:16 +0100 Subject: [PATCH 4/4] tests and small fixes --- snapshots/ERC7683Allocator_open.json | 2 +- snapshots/ERC7683Allocator_openFor.json | 2 +- snapshots/HybridAllocatorTest.json | 22 +- snapshots/OnChainAllocatorTest.json | 18 +- src/allocators/HybridAllocator.sol | 14 +- src/allocators/OnChainAllocator.sol | 8 +- src/test/OnChainAllocationCaller.sol | 13 +- test/HybridAllocator.t.sol | 809 +++++++++++++++++++++++- test/OnChainAllocator.t.sol | 635 ++++++++++++++++++- 9 files changed, 1455 insertions(+), 68 deletions(-) diff --git a/snapshots/ERC7683Allocator_open.json b/snapshots/ERC7683Allocator_open.json index 3890742..ce6c061 100644 --- a/snapshots/ERC7683Allocator_open.json +++ b/snapshots/ERC7683Allocator_open.json @@ -1,3 +1,3 @@ { - "open_simpleOrder": "168865" + "open_simpleOrder": "168885" } \ No newline at end of file diff --git a/snapshots/ERC7683Allocator_openFor.json b/snapshots/ERC7683Allocator_openFor.json index 9eaebd2..e4d984c 100644 --- a/snapshots/ERC7683Allocator_openFor.json +++ b/snapshots/ERC7683Allocator_openFor.json @@ -1,3 +1,3 @@ { - "openFor_simpleOrder_userHimself": "172309" + "openFor_simpleOrder_userHimself": "172344" } \ No newline at end of file diff --git a/snapshots/HybridAllocatorTest.json b/snapshots/HybridAllocatorTest.json index 6a7e17e..9ec8415 100644 --- a/snapshots/HybridAllocatorTest.json +++ b/snapshots/HybridAllocatorTest.json @@ -1,13 +1,13 @@ { - "allocateAndRegister_erc20Token": "170490", - "allocateAndRegister_erc20Token_emptyAmountInput": "171400", - "allocateAndRegister_multipleTokens": "206420", - "allocateAndRegister_nativeToken": "122053", - "allocateAndRegister_nativeToken_emptyAmountInput": "121889", - "allocateAndRegister_second_erc20Token": "114796", - "allocateAndRegister_second_nativeToken": "104789", - "hybrid_execute_single": "157779", - "hybrid_permit2Allocation_multipleERC20": "253503", - "hybrid_permit2Allocation_singleERC20": "187123", - "hybrid_permit2Allocation_singleERC20_withWitness": "188140" + "allocateAndRegister_erc20Token": "170500", + "allocateAndRegister_erc20Token_emptyAmountInput": "171409", + "allocateAndRegister_multipleTokens": "206429", + "allocateAndRegister_nativeToken": "122063", + "allocateAndRegister_nativeToken_emptyAmountInput": "121899", + "allocateAndRegister_second_erc20Token": "114805", + "allocateAndRegister_second_nativeToken": "104799", + "hybrid_execute_single": "159843", + "hybrid_permit2Allocation_multipleERC20": "255228", + "hybrid_permit2Allocation_singleERC20": "188567", + "hybrid_permit2Allocation_singleERC20_withWitness": "189597" } \ No newline at end of file diff --git a/snapshots/OnChainAllocatorTest.json b/snapshots/OnChainAllocatorTest.json index 9919922..ebad06a 100644 --- a/snapshots/OnChainAllocatorTest.json +++ b/snapshots/OnChainAllocatorTest.json @@ -1,11 +1,11 @@ { - "allocateFor_success_withRegistration": "134199", - "allocate_and_delete_expired_allocation": "66401", - "allocate_erc20": "129672", - "allocate_native": "129432", - "allocate_second_erc20": "97684", - "onchain_execute_double": "346560", - "onchain_execute_single": "220180", - "onchain_permit2Allocation_multipleERC20": "365724", - "onchain_permit2Allocation_singleERC20": "232132" + "allocateFor_success_withRegistration": "134247", + "allocate_and_delete_expired_allocation": "66426", + "allocate_erc20": "129697", + "allocate_native": "129457", + "allocate_second_erc20": "97709", + "onchain_execute_double": "355504", + "onchain_execute_single": "225281", + "onchain_permit2Allocation_multipleERC20": "374116", + "onchain_permit2Allocation_singleERC20": "236970" } \ No newline at end of file diff --git a/src/allocators/HybridAllocator.sol b/src/allocators/HybridAllocator.sol index cc0e186..89d3ae5 100644 --- a/src/allocators/HybridAllocator.sol +++ b/src/allocators/HybridAllocator.sol @@ -452,19 +452,19 @@ contract HybridAllocator is IHybridAllocator { returns (HybridAllocationContext calldata allocationContext) { assembly ("memory-safe") { - // HybridAllocationContext structure: - // 0x00: HybridAllocationContext.offset - // 0x20: nonce - // 0x40: signature.offset - // 0x60: signature.length - // 0x80: signature.content + // context structure + // 0x00: HybridAllocationContext.offset (0x20) + // 0x20: HybridAllocationContext.nonce + // 0x40: HybridAllocationContext.signature.offset (0x40 relative to struct start at 0x20) + // 0x60: HybridAllocationContext.signature.length + // 0x80: HybridAllocationContext.signature.content // required length must be 0x80 + signature length of 64 or 96 bytes (65 bytes will be padded to 96 bytes) let minimumLength := 0xc0 let errorBuffer := or(lt(context.length, minimumLength), gt(context.length, add(minimumLength, 0x20))) // check length of context is valid - errorBuffer := or(errorBuffer, xor(calldataload(add(context.offset, 0x40)), 0x60)) // check signature offset is valid (offset relative to context.offset) + errorBuffer := or(errorBuffer, xor(calldataload(add(context.offset, 0x40)), 0x40)) // check signature offset is valid (0x40 relative to struct start) // Check the signature is valid let calldataSignatureLength := calldataload(add(context.offset, 0x60)) diff --git a/src/allocators/OnChainAllocator.sol b/src/allocators/OnChainAllocator.sol index 9df8c52..d4bc215 100644 --- a/src/allocators/OnChainAllocator.sol +++ b/src/allocators/OnChainAllocator.sol @@ -335,9 +335,9 @@ contract OnChainAllocator is IOnChainAllocator, Utility { revert InvalidExpiration(expires, type(uint32).max); } uint32 expiration = uint32(expires); - nonce = _getNonce(msg.sender, recipient); // Includes command. AL will handle the command, so we remove it by casting to uint248. + nonce = _getNonce(msg.sender, recipient); // Includes command. AL.prepareAllocation( - uint248(nonce), + nonce, recipient, idsAndAmounts, additionalCommitmentAmounts, @@ -366,7 +366,7 @@ contract OnChainAllocator is IOnChainAllocator, Utility { revert InvalidExpiration(expires, type(uint32).max); } uint32 expiration = uint32(expires); - uint256 nonce = _getAndUpdateNonce(msg.sender, recipient); // Includes command. AL will handle the command, so we remove it by casting to uint248. + uint256 nonce = _getAndUpdateNonce(msg.sender, recipient); // Includes command. (bytes32 claimHash, Lock[] memory commitments) = _executeAllocation( nonce, recipient, idsAndAmounts, additionalCommitmentAmounts, arbiter, expiration, typehash, witness @@ -391,7 +391,7 @@ contract OnChainAllocator is IOnChainAllocator, Utility { uint256[] memory previousBalances, bool containsAdditionalCommitments ) = AL.executeAllocation( - uint248(nonce), recipient, idsAndAmounts, additionalCommitmentAmounts, arbiter, expires, typehash, witness + nonce, recipient, idsAndAmounts, additionalCommitmentAmounts, arbiter, expires, typehash, witness ); uint256 minResetPeriod = type(uint256).max; diff --git a/src/test/OnChainAllocationCaller.sol b/src/test/OnChainAllocationCaller.sol index 1db8bff..896b57d 100644 --- a/src/test/OnChainAllocationCaller.sol +++ b/src/test/OnChainAllocationCaller.sol @@ -23,15 +23,16 @@ contract OnChainAllocationCaller { uint8 todo ) external { uint256 nonce; + uint256[] memory emptyAdditionalAmounts = new uint256[](idsAndAmounts.length); if (todo == 0) { // Correctly deposit and register - nonce = ALLOCATOR.prepareAllocation(recipient, idsAndAmounts, arbiter, expires, typehash, witness, ''); + nonce = ALLOCATOR.prepareAllocation(recipient, idsAndAmounts, emptyAdditionalAmounts, arbiter, expires, typehash, witness, ''); ITheCompact(COMPACT).batchDepositAndRegisterFor( recipient, idsAndAmounts, arbiter, nonce, expires, typehash, witness ); } else if (todo == 1) { // Only deposit, do not register - nonce = ALLOCATOR.prepareAllocation(recipient, idsAndAmounts, arbiter, expires, typehash, witness, ''); + nonce = ALLOCATOR.prepareAllocation(recipient, idsAndAmounts, emptyAdditionalAmounts, arbiter, expires, typehash, witness, ''); ITheCompact(COMPACT).batchDeposit(idsAndAmounts, recipient); } else if (todo == 2) { // Do not prepare, but deposit and register @@ -39,15 +40,15 @@ contract OnChainAllocationCaller { recipient, idsAndAmounts, arbiter, nonce, expires, typehash, witness ); } else if (todo == 3) { - nonce = ALLOCATOR.prepareAllocation(recipient, idsAndAmounts, arbiter, expires, typehash, witness, ''); + nonce = ALLOCATOR.prepareAllocation(recipient, idsAndAmounts, emptyAdditionalAmounts, arbiter, expires, typehash, witness, ''); } else { // Correctly deposit and register - nonce = ALLOCATOR.prepareAllocation(recipient, idsAndAmounts, arbiter, expires, typehash, witness, ''); + nonce = ALLOCATOR.prepareAllocation(recipient, idsAndAmounts, emptyAdditionalAmounts, arbiter, expires, typehash, witness, ''); ITheCompact(COMPACT).batchDepositAndRegisterFor( recipient, idsAndAmounts, arbiter, nonce, expires, typehash, witness ); - ALLOCATOR.executeAllocation(recipient, idsAndAmounts, arbiter, expires, typehash, witness, ''); + ALLOCATOR.executeAllocation(recipient, idsAndAmounts, emptyAdditionalAmounts, arbiter, expires, typehash, witness, ''); } - ALLOCATOR.executeAllocation(recipient, idsAndAmounts, arbiter, expires, typehash, witness, ''); + ALLOCATOR.executeAllocation(recipient, idsAndAmounts, emptyAdditionalAmounts, arbiter, expires, typehash, witness, ''); } } diff --git a/test/HybridAllocator.t.sol b/test/HybridAllocator.t.sol index 499cb5c..5d1a96a 100644 --- a/test/HybridAllocator.t.sol +++ b/test/HybridAllocator.t.sol @@ -20,6 +20,7 @@ import { BATCH_COMPACT_TYPESTRING_FRAGMENT_THREE, BATCH_COMPACT_TYPESTRING_FRAGMENT_TWO, BatchCompact, + LOCK_TYPEHASH, Lock } from '@uniswap/the-compact/types/EIP712Types.sol'; import {ResetPeriod} from '@uniswap/the-compact/types/ResetPeriod.sol'; @@ -254,6 +255,11 @@ contract HybridAllocatorTest is Test, TestHelper { return uint256(0x03) << 248 | uint256(uint160(sponsor)) << 88 | uint256(freeNonce); } + /// @dev Creates an empty additionalCommitmentAmounts array of the specified length + function _emptyAmounts(uint256 length) internal pure returns (uint256[] memory) { + return new uint256[](length); + } + function _createPermit2Signature( ISignatureTransfer.TokenPermissions[] memory permitted, DepositDetails memory details, @@ -406,7 +412,7 @@ contract HybridAllocatorTest is Test, TestHelper { abi.encodeWithSelector(AllocatorLib.InvalidAllocatorId.selector, allocatorId, allocator.ALLOCATOR_ID()) ); allocator.prepareAllocation( - user, idsAndAmounts, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, bytes32(0), '' + user, idsAndAmounts, _emptyAmounts(1), arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, bytes32(0), '' ); } @@ -415,7 +421,7 @@ contract HybridAllocatorTest is Test, TestHelper { uint88 beforeNonces = allocator.nonces(); // call prepare directly uint256 returnedNonce = allocator.prepareAllocation( - user, idsAndAmounts, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, bytes32(0), '' + user, idsAndAmounts, _emptyAmounts(1), arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, bytes32(0), '' ); // HybridAllocator passes just the counter to AL.prepareAllocation, so nonce is: command | counter // (address(0) is used because HybridAllocator doesn't embed recipient in nonce pre-command) @@ -1637,7 +1643,17 @@ contract HybridAllocatorTest is Test, TestHelper { // Execute Lock[] memory commitments = allocator.permit2Allocation( - arbiter, user, defaultExpiration, permitted, details, claimHash, '', bytes32(0), signature + arbiter, + user, + defaultExpiration, + permitted, + _emptyAmounts(1), + details, + claimHash, + '', + bytes32(0), + signature, + '' ); vm.snapshotGasLastCall('hybrid_permit2Allocation_singleERC20'); @@ -1700,7 +1716,17 @@ contract HybridAllocatorTest is Test, TestHelper { // Note: The witness parameter should be just the inner content (e.g., "uint256 witness"), // not the full struct definition, as TheCompact wraps it in "Mandate(...)" Lock[] memory commitments = allocator.permit2Allocation( - arbiter, user, defaultExpiration, permitted, details, claimHash, WITNESS_STRING, witness, signature + arbiter, + user, + defaultExpiration, + permitted, + _emptyAmounts(1), + details, + claimHash, + WITNESS_STRING, + witness, + signature, + '' ); vm.snapshotGasLastCall('hybrid_permit2Allocation_singleERC20_withWitness'); @@ -1764,7 +1790,17 @@ contract HybridAllocatorTest is Test, TestHelper { // Execute Lock[] memory commitments = allocator.permit2Allocation( - arbiter, user, defaultExpiration, permitted, details, claimHash, '', bytes32(0), signature + arbiter, + user, + defaultExpiration, + permitted, + _emptyAmounts(2), + details, + claimHash, + '', + bytes32(0), + signature, + '' ); vm.snapshotGasLastCall('hybrid_permit2Allocation_multipleERC20'); @@ -1812,7 +1848,17 @@ contract HybridAllocatorTest is Test, TestHelper { // Should revert with UnauthorizedNonce because command is not PERMIT2_NONCE vm.expectRevert(abi.encodeWithSelector(AllocatorLib.UnauthorizedNonce.selector, bytes1(0x01), user)); allocator.permit2Allocation( - arbiter, user, defaultExpiration, permitted, details, claimHash, '', bytes32(0), signature + arbiter, + user, + defaultExpiration, + permitted, + _emptyAmounts(1), + details, + claimHash, + '', + bytes32(0), + signature, + '' ); } @@ -1836,7 +1882,17 @@ contract HybridAllocatorTest is Test, TestHelper { // Should revert because sponsor in nonce doesn't match depositor vm.expectRevert(abi.encodeWithSelector(AllocatorLib.UnauthorizedNonce.selector, bytes1(0x03), wrongSponsor)); allocator.permit2Allocation( - arbiter, user, defaultExpiration, permitted, details, claimHash, '', bytes32(0), signature + arbiter, + user, + defaultExpiration, + permitted, + _emptyAmounts(1), + details, + claimHash, + '', + bytes32(0), + signature, + '' ); } @@ -1878,7 +1934,17 @@ contract HybridAllocatorTest is Test, TestHelper { emit IOnChainAllocation.Allocated(user, expectedCommitments, nonce, defaultExpiration, claimHash); allocator.permit2Allocation( - arbiter, user, defaultExpiration, permitted, details, claimHash, '', bytes32(0), signature + arbiter, + user, + defaultExpiration, + permitted, + _emptyAmounts(1), + details, + claimHash, + '', + bytes32(0), + signature, + '' ); } @@ -1915,7 +1981,17 @@ contract HybridAllocatorTest is Test, TestHelper { // Execute permit2Allocation allocator.permit2Allocation( - arbiter, user, defaultExpiration, permitted, details, claimHash, '', bytes32(0), signature + arbiter, + user, + defaultExpiration, + permitted, + _emptyAmounts(1), + details, + claimHash, + '', + bytes32(0), + signature, + '' ); // Verify claim is authorized @@ -1953,4 +2029,719 @@ contract HybridAllocatorTest is Test, TestHelper { // Verify claim is no longer authorized (deleted after execution) assertFalse(allocator.isClaimAuthorized(claimHash, address(0), address(0), 0, 0, new uint256[2][](0), '')); } + + /* --------------------------------------------------------------------- */ + /* additionalCommitmentAmounts Tests */ + /* --------------------------------------------------------------------- */ + + string constant HYBRID_ALLOCATION_CONTEXT_TYPESTRING = + 'HybridAllocationContext(bytes32 claimHash,Lock[] additionalCommitments)Lock(bytes12 lockTag,address token,uint256 amount)'; + bytes32 constant HYBRID_ALLOCATION_CONTEXT_TYPEHASH = keccak256(bytes(HYBRID_ALLOCATION_CONTEXT_TYPESTRING)); + + /// @dev Creates a signature for HybridAllocationContext + function _createContextSignature( + bytes32 claimHash, + Lock[] memory commitments, + uint256[] memory additionalCommitmentAmounts, + uint256 signerPk, + bool compactSignature + ) internal view returns (bytes memory) { + // Create commitment hashes using additionalCommitmentAmounts instead of commitment.amount + bytes32[] memory commitmentHashes = new bytes32[](commitments.length); + for (uint256 i = 0; i < commitments.length; i++) { + commitmentHashes[i] = keccak256( + abi.encode(LOCK_TYPEHASH, commitments[i].lockTag, commitments[i].token, additionalCommitmentAmounts[i]) + ); + } + + bytes32 commitmentsHash = keccak256(abi.encodePacked(commitmentHashes)); + bytes32 hybridAllocationHash = + keccak256(abi.encode(HYBRID_ALLOCATION_CONTEXT_TYPEHASH, claimHash, commitmentsHash)); + + bytes32 domainSeparator = compact.DOMAIN_SEPARATOR(); + bytes32 digest = keccak256(abi.encodePacked(bytes2(0x1901), domainSeparator, hybridAllocationHash)); + + if (compactSignature) { + (bytes32 r, bytes32 vs) = vm.signCompact(signerPk, digest); + return abi.encodePacked(r, vs); + } else { + (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPk, digest); + return abi.encodePacked(r, s, v); + } + } + + /// @dev Encodes a HybridAllocationContext for use in prepareAllocation/executeAllocation + function _encodeHybridAllocationContext(uint256 nonce, bytes memory signature) + internal + pure + returns (bytes memory) + { + IHybridAllocator.HybridAllocationContext memory context = + IHybridAllocator.HybridAllocationContext({nonce: nonce, signature: signature}); + return abi.encode(context); + } + + /// @notice Test permit2Allocation with additionalCommitmentAmounts using existing balance + function test_permit2Allocation_withAdditionalCommitments() public { + uint256 existingBalance = defaultAmount; + uint256 newDeposit = defaultAmount; + uint256 additionalCommitment = existingBalance / 2; + uint256 totalCommitment = newDeposit + additionalCommitment; + + // First, deposit tokens directly to the user (existing balance) + usdc.mint(user, existingBalance); + vm.startPrank(user); + usdc.approve(address(compact), existingBalance); + bytes12 lockTag = _getLockTag(); + uint256 id = compact.depositERC20(address(usdc), lockTag, existingBalance, user); + vm.stopPrank(); + + // Verify existing balance + assertEq(compact.balanceOf(user, id), existingBalance); + + // Now do a permit2 allocation with additional commitment from existing balance + uint88 freeNonce = 1; + uint256 nonce = _createPermit2Nonce(user, freeNonce); + + // Mint additional tokens for permit2 deposit + usdc.mint(user, newDeposit); + + // Prepare token permissions for permit2 + ISignatureTransfer.TokenPermissions[] memory permitted = _createTokenPermissions(address(usdc), newDeposit); + + // Prepare deposit details + DepositDetails memory details = _createDepositDetails(nonce, defaultExpiration, lockTag); + + // Compute claimHash with total commitment amount + bytes32[] memory commitmentHashes = new bytes32[](1); + commitmentHashes[0] = _computeCommitmentHash(id, totalCommitment); + + bytes32 claimHash = keccak256( + abi.encode( + BATCH_COMPACT_TYPEHASH, + arbiter, + user, + nonce, + defaultExpiration, + keccak256(abi.encodePacked(commitmentHashes)) + ) + ); + + // Create Permit2 signature + bytes memory permit2Signature = _createPermit2Signature(permitted, details, claimHash, userPrivateKey); + + // Create additional commitment amounts array + uint256[] memory additionalCommitmentAmounts = new uint256[](1); + additionalCommitmentAmounts[0] = additionalCommitment; + + // Create the commitments array for context signature + Lock[] memory commitments = new Lock[](1); + commitments[0] = Lock({lockTag: lockTag, token: address(usdc), amount: totalCommitment}); + + // Create 64 bytes compact signature + uint256 snap = vm.snapshot(); + // Create allocator signature for the additional commitments (context) + bytes memory contextSignature = + _createContextSignature(claimHash, commitments, additionalCommitmentAmounts, signerPrivateKey, true); // true creating 64 bytes signature + + // Execute permit2Allocation with the context signature + allocator.permit2Allocation( + arbiter, + user, + defaultExpiration, + permitted, + additionalCommitmentAmounts, + details, + claimHash, + '', + bytes32(0), + permit2Signature, + contextSignature + ); + + // Verify claim is authorized + assertTrue(allocator.isClaimAuthorized(claimHash, address(0), address(0), 0, 0, new uint256[2][](0), '')); + + // Verify user has total balance (existing + new deposit) + assertEq(compact.balanceOf(user, id), existingBalance + newDeposit); + + // Verify the registration on the compact + assertTrue(compact.isRegistered(user, claimHash, BATCH_COMPACT_TYPEHASH)); + + // Revert and check with 65 byte signature + vm.revertTo(snap); + + contextSignature = + _createContextSignature(claimHash, commitments, additionalCommitmentAmounts, signerPrivateKey, false); // false creating 65 bytes signature + + // Execute permit2Allocation with the context signature + allocator.permit2Allocation( + arbiter, + user, + defaultExpiration, + permitted, + additionalCommitmentAmounts, + details, + claimHash, + '', + bytes32(0), + permit2Signature, + contextSignature + ); + + // Verify claim is authorized + assertTrue(allocator.isClaimAuthorized(claimHash, address(0), address(0), 0, 0, new uint256[2][](0), '')); + + // Verify user has total balance (existing + new deposit) + assertEq(compact.balanceOf(user, id), existingBalance + newDeposit); + + // Verify the registration on the compact + assertTrue(compact.isRegistered(user, claimHash, BATCH_COMPACT_TYPEHASH)); + } + + /// @notice Test permit2Allocation with additionalCommitmentAmounts and witness in claimHash + function test_permit2Allocation_withAdditionalCommitments_withWitness() public { + uint256 existingBalance = defaultAmount; + uint256 newDeposit = defaultAmount; + uint256 additionalCommitment = existingBalance / 2; + uint256 totalCommitment = newDeposit + additionalCommitment; + + // First, deposit tokens directly to the user (existing balance) + usdc.mint(user, existingBalance); + vm.startPrank(user); + usdc.approve(address(compact), existingBalance); + bytes12 lockTag = _getLockTag(); + uint256 id = compact.depositERC20(address(usdc), lockTag, existingBalance, user); + vm.stopPrank(); + + // Now do a permit2 allocation with additional commitment from existing balance + uint88 freeNonce = 1; + uint256 nonce = _createPermit2Nonce(user, freeNonce); + + // Mint additional tokens for permit2 deposit + usdc.mint(user, newDeposit); + + // Prepare token permissions for permit2 + ISignatureTransfer.TokenPermissions[] memory permitted = _createTokenPermissions(address(usdc), newDeposit); + + // Prepare deposit details + DepositDetails memory details = _createDepositDetails(nonce, defaultExpiration, lockTag); + + // Create witness + uint256 witnessValue = 12_345; + bytes32 witness = keccak256(abi.encode(WITNESS_TYPEHASH, witnessValue)); + + // Compute claimHash with total commitment amount AND witness + bytes32[] memory commitmentHashes = new bytes32[](1); + commitmentHashes[0] = _computeCommitmentHash(id, totalCommitment); + + bytes32 claimHash = keccak256( + abi.encode( + BATCH_COMPACT_TYPEHASH_WITH_WITNESS, + arbiter, + user, + nonce, + defaultExpiration, + keccak256(abi.encodePacked(commitmentHashes)), + witness + ) + ); + + // Create Permit2 signature with witness + bytes memory permit2Signature = + _createPermit2SignatureWithWitness(permitted, details, claimHash, userPrivateKey); + + // Create additional commitment amounts array + uint256[] memory additionalCommitmentAmounts = new uint256[](1); + additionalCommitmentAmounts[0] = additionalCommitment; + + // Create the commitments array for context signature + Lock[] memory commitments = new Lock[](1); + commitments[0] = Lock({lockTag: lockTag, token: address(usdc), amount: totalCommitment}); + + // Create allocator signature for the additional commitments (context) + bytes memory contextSignature = + _createContextSignature(claimHash, commitments, additionalCommitmentAmounts, signerPrivateKey, true); + + // Execute permit2Allocation with the context signature and witness + allocator.permit2Allocation( + arbiter, + user, + defaultExpiration, + permitted, + additionalCommitmentAmounts, + details, + claimHash, + WITNESS_STRING, + witness, + permit2Signature, + contextSignature + ); + + // Verify claim is authorized + assertTrue(allocator.isClaimAuthorized(claimHash, address(0), address(0), 0, 0, new uint256[2][](0), '')); + + // Verify user has total balance (existing + new deposit) + assertEq(compact.balanceOf(user, id), existingBalance + newDeposit); + } + + /// @notice Test permit2Allocation reverts when additionalCommitmentAmounts exceed existing balance + function test_permit2Allocation_revert_insufficientBalanceForAdditionalCommitments() public { + uint256 existingBalance = defaultAmount; + uint256 newDeposit = defaultAmount; + uint256 additionalCommitment = existingBalance + 1; // Exceed existing balance + + // First, deposit tokens directly to the user (existing balance) + usdc.mint(user, existingBalance); + vm.startPrank(user); + usdc.approve(address(compact), existingBalance); + bytes12 lockTag = _getLockTag(); + compact.depositERC20(address(usdc), lockTag, existingBalance, user); + vm.stopPrank(); + + // Now try permit2 allocation with additionalCommitment > existing balance + uint88 freeNonce = 1; + uint256 nonce = _createPermit2Nonce(user, freeNonce); + + // Mint additional tokens for permit2 deposit + usdc.mint(user, newDeposit); + + // Prepare token permissions for permit2 + ISignatureTransfer.TokenPermissions[] memory permitted = _createTokenPermissions(address(usdc), newDeposit); + + // Prepare deposit details + DepositDetails memory details = _createDepositDetails(nonce, defaultExpiration, lockTag); + + // Compute claimHash (doesn't matter, will revert before) + uint256 id = AllocatorLib.toId(lockTag, address(usdc)); + bytes32[] memory commitmentHashes = new bytes32[](1); + commitmentHashes[0] = _computeCommitmentHash(id, newDeposit + additionalCommitment); + + bytes32 claimHash = keccak256( + abi.encode( + BATCH_COMPACT_TYPEHASH, + arbiter, + user, + nonce, + defaultExpiration, + keccak256(abi.encodePacked(commitmentHashes)) + ) + ); + + // Create Permit2 signature + bytes memory permit2Signature = _createPermit2Signature(permitted, details, claimHash, userPrivateKey); + + // Create additional commitment amounts array + uint256[] memory additionalCommitmentAmounts = new uint256[](1); + additionalCommitmentAmounts[0] = additionalCommitment; + + // Create context signature (will not be validated since we'll revert before) + Lock[] memory commitments = new Lock[](1); + commitments[0] = Lock({lockTag: lockTag, token: address(usdc), amount: newDeposit + additionalCommitment}); + bytes memory contextSignature = + _createContextSignature(claimHash, commitments, additionalCommitmentAmounts, signerPrivateKey, true); + + // Execute permit2Allocation should revert + vm.expectRevert( + abi.encodeWithSelector( + AllocatorLib.InvalidBalanceForAdditionalCommitments.selector, + existingBalance, // available balance + additionalCommitment // requested additional commitment + ) + ); + allocator.permit2Allocation( + arbiter, + user, + defaultExpiration, + permitted, + additionalCommitmentAmounts, + details, + claimHash, + '', + bytes32(0), + permit2Signature, + contextSignature + ); + } + + /// @notice Test permit2Allocation reverts with invalid context signature for additionalCommitmentAmounts + function test_permit2Allocation_revert_invalidContextSignature() public { + uint256 existingBalance = defaultAmount; + uint256 newDeposit = defaultAmount; + uint256 additionalCommitment = existingBalance / 2; + uint256 totalCommitment = newDeposit + additionalCommitment; + + // First, deposit tokens directly to the user (existing balance) + usdc.mint(user, existingBalance); + vm.startPrank(user); + usdc.approve(address(compact), existingBalance); + bytes12 lockTag = _getLockTag(); + uint256 id = compact.depositERC20(address(usdc), lockTag, existingBalance, user); + vm.stopPrank(); + + // Now do a permit2 allocation with additional commitment from existing balance + uint88 freeNonce = 1; + uint256 nonce = _createPermit2Nonce(user, freeNonce); + + // Mint additional tokens for permit2 deposit + usdc.mint(user, newDeposit); + + // Prepare token permissions for permit2 + ISignatureTransfer.TokenPermissions[] memory permitted = _createTokenPermissions(address(usdc), newDeposit); + + // Prepare deposit details + DepositDetails memory details = _createDepositDetails(nonce, defaultExpiration, lockTag); + + // Compute claimHash with total commitment amount + bytes32[] memory commitmentHashes = new bytes32[](1); + commitmentHashes[0] = _computeCommitmentHash(id, totalCommitment); + + bytes32 claimHash = keccak256( + abi.encode( + BATCH_COMPACT_TYPEHASH, + arbiter, + user, + nonce, + defaultExpiration, + keccak256(abi.encodePacked(commitmentHashes)) + ) + ); + + // Create Permit2 signature + bytes memory permit2Signature = _createPermit2Signature(permitted, details, claimHash, userPrivateKey); + + // Create additional commitment amounts array + uint256[] memory additionalCommitmentAmounts = new uint256[](1); + additionalCommitmentAmounts[0] = additionalCommitment; + + // Create commitments for context signature + Lock[] memory commitments = new Lock[](1); + commitments[0] = Lock({lockTag: lockTag, token: address(usdc), amount: totalCommitment}); + + // Create INVALID context signature - signed with wrong key (user instead of signer) + bytes memory invalidContextSignature = + _createContextSignature(claimHash, commitments, additionalCommitmentAmounts, userPrivateKey, true); + + // Execute permit2Allocation should revert with InvalidSignature + vm.expectRevert(IHybridAllocator.InvalidSignature.selector); + allocator.permit2Allocation( + arbiter, + user, + defaultExpiration, + permitted, + additionalCommitmentAmounts, + details, + claimHash, + '', + bytes32(0), + permit2Signature, + invalidContextSignature + ); + } + + /// @notice Test additionalCommitmentAmounts array length must match permitted length + function test_permit2Allocation_revert_invalidAdditionalCommitmentsLength() public { + bytes12 lockTag = _getLockTag(); + uint88 freeNonce = 1; + uint256 nonce = _createPermit2Nonce(user, freeNonce); + + // Prepare token permissions + ISignatureTransfer.TokenPermissions[] memory permitted = _createTokenPermissions(address(usdc), defaultAmount); + + // Prepare deposit details + DepositDetails memory details = _createDepositDetails(nonce, defaultExpiration, lockTag); + + // Create dummy claimHash and signature + bytes32 claimHash = bytes32(uint256(1)); + bytes memory signature = new bytes(64); + + // Create additionalCommitmentAmounts with wrong length + uint256[] memory additionalCommitmentAmounts = new uint256[](2); // Should be 1 + + vm.expectRevert(abi.encodeWithSelector(AllocatorLib.InvalidAdditionalCommitmentsLength.selector, 2, 1)); + allocator.permit2Allocation( + arbiter, + user, + defaultExpiration, + permitted, + additionalCommitmentAmounts, + details, + claimHash, + '', + bytes32(0), + signature, + '' + ); + } + + /// @notice Test prepareAllocation and executeAllocation flow with additionalCommitmentAmounts using off-chain nonce + /// forge-config: default.isolate = false + function test_prepareAndExecuteAllocation_withAdditionalCommitments() public { + address recipient = makeAddr('recipient'); + uint256 existingBalance = defaultAmount; + uint256 newDeposit = defaultAmount; + uint256 additionalCommitment = existingBalance / 2; + uint256 totalCommitment = newDeposit + additionalCommitment; + + // First, deposit tokens directly to the recipient (existing balance) + usdc.mint(user, existingBalance); + vm.startPrank(user); + usdc.approve(address(compact), existingBalance); + bytes12 lockTag = _getLockTag(); + uint256 id = compact.depositERC20(address(usdc), lockTag, existingBalance, recipient); + vm.stopPrank(); + + // Verify existing balance + assertEq(compact.balanceOf(recipient, id), existingBalance); + + // Create off-chain nonce for the recipient + uint88 freeNonce = 1; + uint256 nonce = _composeNonceUint(OFF_CHAIN_NONCE, recipient, freeNonce); + + // Create idsAndAmounts for the new deposit + uint256[2][] memory idsAndAmounts = new uint256[2][](1); + idsAndAmounts[0][0] = id; + idsAndAmounts[0][1] = newDeposit; + + // Create additional commitment amounts array + uint256[] memory additionalCommitmentAmounts = new uint256[](1); + additionalCommitmentAmounts[0] = additionalCommitment; + + // Compute claim hash with total commitment + Lock[] memory commitments = new Lock[](1); + commitments[0] = Lock({lockTag: lockTag, token: address(usdc), amount: totalCommitment}); + bytes32 claimHash = _toBatchCompactHash( + BatchCompact({ + arbiter: arbiter, + sponsor: recipient, + nonce: nonce, + expires: defaultExpiration, + commitments: commitments + }) + ); + + // Create signer's signature for the HybridAllocationContext + bytes memory contextSignature = + _createContextSignature(claimHash, commitments, additionalCommitmentAmounts, signerPrivateKey, true); + + // Encode the HybridAllocationContext + bytes memory context = _encodeHybridAllocationContext(nonce, contextSignature); + + // Fund and approve for the deposit + usdc.mint(address(this), newDeposit); + usdc.approve(address(compact), newDeposit); + + // Prepare allocation with context (off-chain nonce) + uint256 returnedNonce = allocator.prepareAllocation( + recipient, + idsAndAmounts, + additionalCommitmentAmounts, + arbiter, + defaultExpiration, + BATCH_COMPACT_TYPEHASH, + bytes32(0), + context + ); + assertEq(returnedNonce, nonce); + + // Deposit via Compact + ITheCompact(compact).batchDeposit(idsAndAmounts, recipient); + + // Register the claim + vm.prank(recipient); + ITheCompact(compact).register(claimHash, BATCH_COMPACT_TYPEHASH); + + // Execute allocation with context + allocator.executeAllocation( + recipient, + idsAndAmounts, + additionalCommitmentAmounts, + arbiter, + defaultExpiration, + BATCH_COMPACT_TYPEHASH, + bytes32(0), + context + ); + + // Verify claim is authorized + assertTrue(allocator.isClaimAuthorized(claimHash, address(0), address(0), 0, 0, new uint256[2][](0), '')); + + // Verify recipient has total balance (existing + new deposit) + assertEq(compact.balanceOf(recipient, id), existingBalance + newDeposit); + + // Verify nonces counter was NOT incremented (off-chain nonces don't affect on-chain counter) + assertEq(allocator.nonces(), 0); + } + + /// @notice Test that executeAllocation reverts with additionalCommitmentAmounts when no context/signature is provided + /// forge-config: default.isolate = false + function test_executeAllocation_revert_additionalCommitmentsWithoutContext() public { + address recipient = makeAddr('recipient'); + uint256 additionalCommitment = defaultAmount / 2; + uint256 totalCommitment = defaultAmount + additionalCommitment; + + // First, deposit tokens directly to the recipient (existing balance) + usdc.mint(user, defaultAmount); + vm.startPrank(user); + usdc.approve(address(compact), defaultAmount); + bytes12 lockTag = _getLockTag(); + uint256 id = compact.depositERC20(address(usdc), lockTag, defaultAmount, recipient); + vm.stopPrank(); + + // Verify existing balance + assertEq(compact.balanceOf(recipient, id), defaultAmount); + + uint256[2][] memory idsAndAmounts = new uint256[2][](1); + idsAndAmounts[0][0] = id; + idsAndAmounts[0][1] = defaultAmount; + + // Create non-zero additionalCommitmentAmounts + uint256[] memory additionalCommitmentAmounts = new uint256[](1); + additionalCommitmentAmounts[0] = additionalCommitment; + + // Fund and approve for the new deposit + usdc.mint(address(this), defaultAmount); + usdc.approve(address(compact), defaultAmount); + + // prepareAllocation without context succeeds (on-chain nonce is used) + // Note: prepareAllocation uses (nonces + 1) but doesn't increment, executeAllocation uses ++nonces + allocator.prepareAllocation( + recipient, + idsAndAmounts, + additionalCommitmentAmounts, + arbiter, + defaultExpiration, + BATCH_COMPACT_TYPEHASH, + bytes32(0), + '' + ); + + // Deposit via Compact + ITheCompact(compact).batchDeposit(idsAndAmounts, recipient); + + // Compute the on-chain nonce that executeAllocation will use (++nonces, so 1 since nonces starts at 0) + uint256 expectedNonce = _composeNonceUint(ON_CHAIN_NONCE, address(0), 1); + + // Compute the claim hash with the total commitment (including additionalCommitmentAmounts) + Lock[] memory commitments = new Lock[](1); + commitments[0] = Lock({lockTag: lockTag, token: address(usdc), amount: totalCommitment}); + bytes32 claimHash = _toBatchCompactHash( + BatchCompact({ + arbiter: arbiter, + sponsor: recipient, + nonce: expectedNonce, + expires: defaultExpiration, + commitments: commitments + }) + ); + + // Register the claim so we can reach the InvalidSignature check + vm.prank(recipient); + ITheCompact(compact).register(claimHash, BATCH_COMPACT_TYPEHASH); + + // executeAllocation without context should revert because additionalCommitmentAmounts requires a signature + vm.expectRevert(IHybridAllocator.InvalidSignature.selector); + allocator.executeAllocation( + recipient, + idsAndAmounts, + additionalCommitmentAmounts, + arbiter, + defaultExpiration, + BATCH_COMPACT_TYPEHASH, + bytes32(0), + '' + ); + } + + /// @notice Test prepareAllocation and executeAllocation flow with 65-byte signature + /// forge-config: default.isolate = false + function test_prepareAndExecuteAllocation_withAdditionalCommitments_65byteSignature() public { + address recipient = makeAddr('recipient'); + uint256 existingBalance = defaultAmount; + uint256 newDeposit = defaultAmount; + uint256 additionalCommitment = existingBalance / 2; + uint256 totalCommitment = newDeposit + additionalCommitment; + + // First, deposit tokens directly to the recipient (existing balance) + usdc.mint(user, existingBalance); + vm.startPrank(user); + usdc.approve(address(compact), existingBalance); + bytes12 lockTag = _getLockTag(); + uint256 id = compact.depositERC20(address(usdc), lockTag, existingBalance, recipient); + vm.stopPrank(); + + // Create off-chain nonce for the recipient + uint88 freeNonce = 1; + uint256 nonce = _composeNonceUint(OFF_CHAIN_NONCE, recipient, freeNonce); + + // Create idsAndAmounts for the new deposit + uint256[2][] memory idsAndAmounts = new uint256[2][](1); + idsAndAmounts[0][0] = id; + idsAndAmounts[0][1] = newDeposit; + + // Create additional commitment amounts array + uint256[] memory additionalCommitmentAmounts = new uint256[](1); + additionalCommitmentAmounts[0] = additionalCommitment; + + // Compute claim hash with total commitment + Lock[] memory commitments = new Lock[](1); + commitments[0] = Lock({lockTag: lockTag, token: address(usdc), amount: totalCommitment}); + bytes32 claimHash = _toBatchCompactHash( + BatchCompact({ + arbiter: arbiter, + sponsor: recipient, + nonce: nonce, + expires: defaultExpiration, + commitments: commitments + }) + ); + + // Create 65-byte signer's signature for the HybridAllocationContext + bytes memory contextSignature65 = + _createContextSignature(claimHash, commitments, additionalCommitmentAmounts, signerPrivateKey, false); + + // Encode the HybridAllocationContext with 65-byte signature + bytes memory context = _encodeHybridAllocationContext(nonce, contextSignature65); + + // Fund and approve for the deposit + usdc.mint(address(this), newDeposit); + usdc.approve(address(compact), newDeposit); + + // Prepare allocation with context + allocator.prepareAllocation( + recipient, + idsAndAmounts, + additionalCommitmentAmounts, + arbiter, + defaultExpiration, + BATCH_COMPACT_TYPEHASH, + bytes32(0), + context + ); + + // Deposit via Compact + ITheCompact(compact).batchDeposit(idsAndAmounts, recipient); + + // Register the claim + vm.prank(recipient); + ITheCompact(compact).register(claimHash, BATCH_COMPACT_TYPEHASH); + + // Execute allocation with context (65-byte signature) + allocator.executeAllocation( + recipient, + idsAndAmounts, + additionalCommitmentAmounts, + arbiter, + defaultExpiration, + BATCH_COMPACT_TYPEHASH, + bytes32(0), + context + ); + + // Verify claim is authorized + assertTrue(allocator.isClaimAuthorized(claimHash, address(0), address(0), 0, 0, new uint256[2][](0), '')); + + // Verify recipient has total balance + assertEq(compact.balanceOf(recipient, id), existingBalance + newDeposit); + } } diff --git a/test/OnChainAllocator.t.sol b/test/OnChainAllocator.t.sol index 91927eb..b62507a 100644 --- a/test/OnChainAllocator.t.sol +++ b/test/OnChainAllocator.t.sol @@ -233,6 +233,11 @@ contract OnChainAllocatorTest is Test, TestHelper { return uint256(0x03) << 248 | uint256(uint160(sponsor)) << 88 | uint256(freeNonce); } + /// @dev Creates an empty additionalCommitmentAmounts array of the specified length + function _emptyAmounts(uint256 length) internal pure returns (uint256[] memory) { + return new uint256[](length); + } + function _createPermit2Signature( ISignatureTransfer.TokenPermissions[] memory permitted, DepositDetails memory details, @@ -1256,7 +1261,14 @@ contract OnChainAllocatorTest is Test, TestHelper { abi.encodeWithSelector(AllocatorLib.InvalidAllocatorId.selector, allocatorId, allocator.ALLOCATOR_ID()) ); allocator.prepareAllocation( - recipient, idsAndAmounts, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, bytes32(0), '' + recipient, + idsAndAmounts, + _emptyAmounts(1), + arbiter, + defaultExpiration, + BATCH_COMPACT_TYPEHASH, + bytes32(0), + '' ); } @@ -1266,7 +1278,14 @@ contract OnChainAllocatorTest is Test, TestHelper { // call from an arbitrary EOA (caller) vm.prank(caller); uint256 returnedNonce = allocator.prepareAllocation( - recipient, idsAndAmounts, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, bytes32(0), '' + recipient, + idsAndAmounts, + _emptyAmounts(1), + arbiter, + defaultExpiration, + BATCH_COMPACT_TYPEHASH, + bytes32(0), + '' ); assertEq(returnedNonce, _composeNonceUint(address(0), 1)); @@ -1284,7 +1303,14 @@ contract OnChainAllocatorTest is Test, TestHelper { ) ); allocator.prepareAllocation( - recipient, idsAndAmounts, arbiter, uint256(type(uint32).max) + 1, BATCH_COMPACT_TYPEHASH, bytes32(0), '' + recipient, + idsAndAmounts, + _emptyAmounts(1), + arbiter, + uint256(type(uint32).max) + 1, + BATCH_COMPACT_TYPEHASH, + bytes32(0), + '' ); } @@ -1298,7 +1324,14 @@ contract OnChainAllocatorTest is Test, TestHelper { ) ); allocator.executeAllocation( - recipient, idsAndAmounts, arbiter, uint256(type(uint32).max) + 1, BATCH_COMPACT_TYPEHASH, bytes32(0), '' + recipient, + idsAndAmounts, + _emptyAmounts(1), + arbiter, + uint256(type(uint32).max) + 1, + BATCH_COMPACT_TYPEHASH, + bytes32(0), + '' ); } @@ -1351,13 +1384,27 @@ contract OnChainAllocatorTest is Test, TestHelper { // prepare for the recipient using caller 1 vm.prank(recipient); uint256 nonce1 = allocator.prepareAllocation( - recipient, idsAndAmounts, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, bytes32(0), '' + recipient, + idsAndAmounts, + _emptyAmounts(1), + arbiter, + defaultExpiration, + BATCH_COMPACT_TYPEHASH, + bytes32(0), + '' ); // prepare for second recipient - DIFFERENT CALLER TO RECEIVE A DIFFERENT NONCE SLOT vm.prank(address(this)); uint256 nonce2 = allocator.prepareAllocation( - recipient, idsAndAmountsCrooked, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, bytes32(0), '' + recipient, + idsAndAmountsCrooked, + _emptyAmounts(2), + arbiter, + defaultExpiration, + BATCH_COMPACT_TYPEHASH, + bytes32(0), + '' ); assertEq(nonce1, _composeNonceUint(address(0), 1)); @@ -1381,14 +1428,28 @@ contract OnChainAllocatorTest is Test, TestHelper { // execute for first allocation vm.prank(recipient); allocator.executeAllocation( - recipient, idsAndAmounts, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, bytes32(0), '' + recipient, + idsAndAmounts, + _emptyAmounts(1), + arbiter, + defaultExpiration, + BATCH_COMPACT_TYPEHASH, + bytes32(0), + '' ); // execute for second allocation which must fail vm.prank(address(this)); vm.expectRevert(abi.encodeWithSelector(AllocatorLib.InvalidPreparation.selector)); allocator.executeAllocation( - recipient, idsAndAmountsCrooked, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, bytes32(0), '' + recipient, + idsAndAmountsCrooked, + _emptyAmounts(2), + arbiter, + defaultExpiration, + BATCH_COMPACT_TYPEHASH, + bytes32(0), + '' ); assertTrue( @@ -1431,13 +1492,27 @@ contract OnChainAllocatorTest is Test, TestHelper { // prepare for first recipient vm.prank(recipient1); uint256 nonce1 = allocator.prepareAllocation( - recipient1, idsAndAmounts, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, bytes32(0), '' + recipient1, + idsAndAmounts, + _emptyAmounts(1), + arbiter, + defaultExpiration, + BATCH_COMPACT_TYPEHASH, + bytes32(0), + '' ); // prepare for second recipient vm.prank(recipient2); uint256 nonce2 = allocator.prepareAllocation( - recipient2, idsAndAmounts, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, bytes32(0), '' + recipient2, + idsAndAmounts, + _emptyAmounts(1), + arbiter, + defaultExpiration, + BATCH_COMPACT_TYPEHASH, + bytes32(0), + '' ); assertEq(nonce1, _composeNonceUint(address(0), 1)); @@ -1461,14 +1536,28 @@ contract OnChainAllocatorTest is Test, TestHelper { // execute for first recipient vm.prank(recipient1); allocator.executeAllocation( - recipient1, idsAndAmounts, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, bytes32(0), '' + recipient1, + idsAndAmounts, + _emptyAmounts(1), + arbiter, + defaultExpiration, + BATCH_COMPACT_TYPEHASH, + bytes32(0), + '' ); // execute for second recipient vm.prank(recipient2); vm.expectRevert(abi.encodeWithSelector(AllocatorLib.InvalidPreparation.selector)); allocator.executeAllocation( - recipient2, idsAndAmounts, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, bytes32(0), '' + recipient2, + idsAndAmounts, + _emptyAmounts(1), + arbiter, + defaultExpiration, + BATCH_COMPACT_TYPEHASH, + bytes32(0), + '' ); assertTrue( @@ -2407,7 +2496,17 @@ contract OnChainAllocatorTest is Test, TestHelper { // Execute Lock[] memory commitments = allocator.permit2Allocation( - arbiter, user, defaultExpiration, permitted, details, claimHash, '', bytes32(0), signature + arbiter, + user, + defaultExpiration, + permitted, + _emptyAmounts(1), + details, + claimHash, + '', + bytes32(0), + signature, + '' ); vm.snapshotGasLastCall('onchain_permit2Allocation_singleERC20'); @@ -2474,7 +2573,17 @@ contract OnChainAllocatorTest is Test, TestHelper { // Execute Lock[] memory commitments = allocator.permit2Allocation( - arbiter, user, defaultExpiration, permitted, details, claimHash, '', bytes32(0), signature + arbiter, + user, + defaultExpiration, + permitted, + _emptyAmounts(2), + details, + claimHash, + '', + bytes32(0), + signature, + '' ); vm.snapshotGasLastCall('onchain_permit2Allocation_multipleERC20'); @@ -2527,7 +2636,17 @@ contract OnChainAllocatorTest is Test, TestHelper { // Should revert with UnauthorizedNonce because command is not PERMIT2_NONCE vm.expectRevert(abi.encodeWithSelector(AllocatorLib.UnauthorizedNonce.selector, bytes1(0x01), user)); allocator.permit2Allocation( - arbiter, user, defaultExpiration, permitted, details, claimHash, '', bytes32(0), signature + arbiter, + user, + defaultExpiration, + permitted, + _emptyAmounts(1), + details, + claimHash, + '', + bytes32(0), + signature, + '' ); } @@ -2551,7 +2670,17 @@ contract OnChainAllocatorTest is Test, TestHelper { abi.encodeWithSelector(IOnChainAllocator.InvalidExpiration.selector, invalidExpiration, type(uint32).max) ); allocator.permit2Allocation( - arbiter, user, invalidExpiration, permitted, details, claimHash, '', bytes32(0), signature + arbiter, + user, + invalidExpiration, + permitted, + _emptyAmounts(1), + details, + claimHash, + '', + bytes32(0), + signature, + '' ); } @@ -2593,7 +2722,17 @@ contract OnChainAllocatorTest is Test, TestHelper { // Should revert because amount exceeds uint224 max vm.expectRevert(abi.encodeWithSelector(IOnChainAllocator.InvalidAmount.selector, largeAmount)); allocator.permit2Allocation( - arbiter, user, defaultExpiration, permitted, details, claimHash, '', bytes32(0), signature + arbiter, + user, + defaultExpiration, + permitted, + _emptyAmounts(1), + details, + claimHash, + '', + bytes32(0), + signature, + '' ); } @@ -2630,7 +2769,17 @@ contract OnChainAllocatorTest is Test, TestHelper { // Execute permit2Allocation allocator.permit2Allocation( - arbiter, user, defaultExpiration, permitted, details, claimHash, '', bytes32(0), signature + arbiter, + user, + defaultExpiration, + permitted, + _emptyAmounts(1), + details, + claimHash, + '', + bytes32(0), + signature, + '' ); // Verify claim is authorized @@ -2703,7 +2852,17 @@ contract OnChainAllocatorTest is Test, TestHelper { // Execute permit2Allocation allocator.permit2Allocation( - arbiter, user, defaultExpiration, permitted, details, claimHash, '', bytes32(0), signature + arbiter, + user, + defaultExpiration, + permitted, + _emptyAmounts(1), + details, + claimHash, + '', + bytes32(0), + signature, + '' ); // Verify claim is authorized before expiration @@ -2750,7 +2909,17 @@ contract OnChainAllocatorTest is Test, TestHelper { // Execute permit2Allocation allocator.permit2Allocation( - arbiter, user, defaultExpiration, permitted, details, claimHash, '', bytes32(0), signature + arbiter, + user, + defaultExpiration, + permitted, + _emptyAmounts(1), + details, + claimHash, + '', + bytes32(0), + signature, + '' ); // Try to transfer - should fail because tokens are allocated @@ -2771,6 +2940,430 @@ contract OnChainAllocatorTest is Test, TestHelper { assertEq(compact.balanceOf(user, id), 0); assertEq(compact.balanceOf(recipient, id), defaultAmount); } + + /* --------------------------------------------------------------------- */ + /* additionalCommitmentAmounts Tests */ + /* --------------------------------------------------------------------- */ + + /// @notice Test executeAllocation with additionalCommitmentAmounts using existing balance + /// forge-config: default.isolate = false + function test_executeAllocation_withAdditionalCommitments() public { + uint256 existingBalance = defaultAmount; + uint256 newDeposit = defaultAmount; + uint256 additionalCommitment = existingBalance / 2; // Use half of existing balance + uint256 totalCommitment = newDeposit + additionalCommitment; + + // First, deposit tokens directly to the recipient (existing balance) + usdc.mint(user, existingBalance); + vm.startPrank(user); + usdc.approve(address(compact), existingBalance); + bytes12 lockTag = _toLockTag(address(allocator), Scope.Multichain, ResetPeriod.TenMinutes); + uint256 id = compact.depositERC20(address(usdc), lockTag, existingBalance, recipient); + vm.stopPrank(); + + // Verify existing balance + assertEq(compact.balanceOf(recipient, id), existingBalance); + + // Now do an allocation with additional commitment from existing balance + uint256[2][] memory idsAndAmounts = new uint256[2][](1); + idsAndAmounts[0][0] = id; + idsAndAmounts[0][1] = newDeposit; + + uint256[] memory additionalCommitmentAmounts = new uint256[](1); + additionalCommitmentAmounts[0] = additionalCommitment; + + // Fund and approve from allocationCaller + usdc.mint(address(allocationCaller), newDeposit); + vm.prank(address(allocationCaller)); + usdc.approve(address(compact), newDeposit); + + // Prepare allocation + vm.prank(address(allocationCaller)); + uint256 nonce = allocator.prepareAllocation( + recipient, + idsAndAmounts, + additionalCommitmentAmounts, + arbiter, + defaultExpiration, + BATCH_COMPACT_TYPEHASH, + bytes32(0), + '' + ); + + // Deposit via Compact from allocationCaller + vm.prank(address(allocationCaller)); + ITheCompact(compact).batchDeposit(idsAndAmounts, recipient); + + // Compute expected claim hash with total commitment + Lock[] memory commitments = new Lock[](1); + commitments[0] = Lock({lockTag: lockTag, token: address(usdc), amount: totalCommitment}); + bytes32 claimHash = _createClaimHash(recipient, arbiter, nonce, defaultExpiration, commitments, bytes32(0)); + + // Register the claim + vm.prank(recipient); + ITheCompact(compact).register(claimHash, BATCH_COMPACT_TYPEHASH); + + // Execute allocation + vm.prank(address(allocationCaller)); + allocator.executeAllocation( + recipient, + idsAndAmounts, + additionalCommitmentAmounts, + arbiter, + defaultExpiration, + BATCH_COMPACT_TYPEHASH, + bytes32(0), + '' + ); + + // Verify recipient has total balance (existing + new deposit) + assertEq(compact.balanceOf(recipient, id), existingBalance + newDeposit); + + // Verify allocation amount is totalCommitment (newDeposit + additionalCommitment) + uint256[2][] memory verifyIdsAndAmounts = new uint256[2][](1); + verifyIdsAndAmounts[0][0] = id; + verifyIdsAndAmounts[0][1] = totalCommitment; + assertTrue( + allocator.isClaimAuthorized( + claimHash, arbiter, recipient, nonce, defaultExpiration, verifyIdsAndAmounts, '' + ) + ); + } + + /// @notice Test executeAllocation reverts when additionalCommitmentAmounts exceed unallocated balance + /// forge-config: default.isolate = false + function test_executeAllocation_revert_insufficientUnallocatedBalance() public { + uint256 existingBalance = defaultAmount; + uint256 newDeposit = defaultAmount; + uint256 totalCommitment = newDeposit + existingBalance; // Total commitment = deposit + additional + + // First, deposit tokens directly to the recipient (existing balance) + usdc.mint(user, existingBalance); + vm.startPrank(user); + usdc.approve(address(compact), existingBalance); + bytes12 lockTag = _toLockTag(address(allocator), Scope.Multichain, ResetPeriod.TenMinutes); + uint256 id = compact.depositERC20(address(usdc), lockTag, existingBalance, recipient); + vm.stopPrank(); + + // First allocation - allocate all of existing balance + Lock[] memory firstCommitments = new Lock[](1); + firstCommitments[0] = Lock({lockTag: lockTag, token: address(usdc), amount: existingBalance}); + vm.prank(recipient); + allocator.allocate(firstCommitments, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, bytes32(0)); + + // Now try to create a second allocation with additionalCommitmentAmounts from the already-allocated balance + uint256[2][] memory idsAndAmounts = new uint256[2][](1); + idsAndAmounts[0][0] = id; + idsAndAmounts[0][1] = newDeposit; + + uint256[] memory additionalCommitmentAmounts = new uint256[](1); + additionalCommitmentAmounts[0] = existingBalance; // Try to use already-allocated balance + + // Fund and approve from allocationCaller + usdc.mint(address(allocationCaller), newDeposit); + vm.prank(address(allocationCaller)); + usdc.approve(address(compact), newDeposit); + + // Prepare allocation + vm.prank(address(allocationCaller)); + uint256 nonce = allocator.prepareAllocation( + recipient, + idsAndAmounts, + additionalCommitmentAmounts, + arbiter, + defaultExpiration, + BATCH_COMPACT_TYPEHASH, + bytes32(0), + '' + ); + + // Deposit via Compact from allocationCaller + vm.prank(address(allocationCaller)); + ITheCompact(compact).batchDeposit(idsAndAmounts, recipient); + + // Compute claim hash with total commitment and register it + Lock[] memory commitments = new Lock[](1); + commitments[0] = Lock({lockTag: lockTag, token: address(usdc), amount: totalCommitment}); + bytes32 claimHash = _createClaimHash(recipient, arbiter, nonce, defaultExpiration, commitments, bytes32(0)); + + // Register the claim so we pass registration check and get to balance check + vm.prank(recipient); + ITheCompact(compact).register(claimHash, BATCH_COMPACT_TYPEHASH); + + // Execute allocation should fail - existing balance is already allocated + vm.prank(address(allocationCaller)); + vm.expectRevert( + abi.encodeWithSelector( + IOnChainAllocator.InsufficientBalance.selector, + recipient, + id, + existingBalance, // previousBalance (what was available before the new deposit) + existingBalance + additionalCommitmentAmounts[0] // required (allocated + additional) + ) + ); + allocator.executeAllocation( + recipient, + idsAndAmounts, + additionalCommitmentAmounts, + arbiter, + defaultExpiration, + BATCH_COMPACT_TYPEHASH, + bytes32(0), + '' + ); + } + + /// @notice Test permit2Allocation with additionalCommitmentAmounts using existing balance + function test_permit2Allocation_withAdditionalCommitments() public { + uint256 existingBalance = defaultAmount; + uint256 newDeposit = defaultAmount; + uint256 additionalCommitment = existingBalance / 2; + uint256 totalCommitment = newDeposit + additionalCommitment; + + // First, deposit tokens directly to the user (existing balance) + usdc.mint(user, existingBalance); + vm.startPrank(user); + usdc.approve(address(compact), existingBalance); + bytes12 lockTag = _getLockTag(); + uint256 id = compact.depositERC20(address(usdc), lockTag, existingBalance, user); + vm.stopPrank(); + + // Verify existing balance + assertEq(compact.balanceOf(user, id), existingBalance); + + // Now do a permit2 allocation with additional commitment from existing balance + uint88 freeNonce = 1; + uint256 nonce = _createPermit2Nonce(user, freeNonce); + + // Mint additional tokens for permit2 deposit + usdc.mint(user, newDeposit); + + // Prepare token permissions for permit2 + ISignatureTransfer.TokenPermissions[] memory permitted = _createTokenPermissions(address(usdc), newDeposit); + + // Prepare deposit details + DepositDetails memory details = _createDepositDetails(nonce, defaultExpiration, lockTag); + + // Compute claimHash with total commitment amount + bytes32[] memory commitmentHashes = new bytes32[](1); + commitmentHashes[0] = _computeCommitmentHash(id, totalCommitment); + + bytes32 claimHash = keccak256( + abi.encode( + BATCH_COMPACT_TYPEHASH, + arbiter, + user, + nonce, + defaultExpiration, + keccak256(abi.encodePacked(commitmentHashes)) + ) + ); + + // Create Permit2 signature + bytes memory signature = _createPermit2Signature(permitted, details, claimHash, userPK); + + // Create additional commitment amounts array + uint256[] memory additionalCommitmentAmounts = new uint256[](1); + additionalCommitmentAmounts[0] = additionalCommitment; + + // Execute permit2Allocation + allocator.permit2Allocation( + arbiter, + user, + defaultExpiration, + permitted, + additionalCommitmentAmounts, + details, + claimHash, + '', + bytes32(0), + signature, + '' + ); + + // Verify claim is authorized with total commitment + uint256[2][] memory idsAndAmounts = new uint256[2][](1); + idsAndAmounts[0][0] = id; + idsAndAmounts[0][1] = totalCommitment; + assertTrue(allocator.isClaimAuthorized(claimHash, arbiter, user, nonce, defaultExpiration, idsAndAmounts, '')); + + // Verify user has total balance (existing + new deposit) + assertEq(compact.balanceOf(user, id), existingBalance + newDeposit); + } + + /// @notice Test permit2Allocation reverts when additionalCommitmentAmounts exceed existing balance + function test_permit2Allocation_revert_insufficientBalanceForAdditionalCommitments() public { + uint256 existingBalance = defaultAmount; + uint256 newDeposit = defaultAmount; + uint256 additionalCommitment = existingBalance + 1; // Exceed existing balance + + // First, deposit tokens directly to the user (existing balance) + usdc.mint(user, existingBalance); + vm.startPrank(user); + usdc.approve(address(compact), existingBalance); + bytes12 lockTag = _getLockTag(); + compact.depositERC20(address(usdc), lockTag, existingBalance, user); + vm.stopPrank(); + + // Now try permit2 allocation with additionalCommitment > existing balance + uint88 freeNonce = 1; + uint256 nonce = _createPermit2Nonce(user, freeNonce); + + // Mint additional tokens for permit2 deposit + usdc.mint(user, newDeposit); + + // Prepare token permissions for permit2 + ISignatureTransfer.TokenPermissions[] memory permitted = _createTokenPermissions(address(usdc), newDeposit); + + // Prepare deposit details + DepositDetails memory details = _createDepositDetails(nonce, defaultExpiration, lockTag); + + // Compute claimHash (doesn't matter, will revert before) + uint256 id = AllocatorLib.toId(lockTag, address(usdc)); + bytes32[] memory commitmentHashes = new bytes32[](1); + commitmentHashes[0] = _computeCommitmentHash(id, newDeposit + additionalCommitment); + + bytes32 claimHash = keccak256( + abi.encode( + BATCH_COMPACT_TYPEHASH, + arbiter, + user, + nonce, + defaultExpiration, + keccak256(abi.encodePacked(commitmentHashes)) + ) + ); + + // Create Permit2 signature + bytes memory signature = _createPermit2Signature(permitted, details, claimHash, userPK); + + // Create additional commitment amounts array + uint256[] memory additionalCommitmentAmounts = new uint256[](1); + additionalCommitmentAmounts[0] = additionalCommitment; + + // Execute permit2Allocation should revert + vm.expectRevert( + abi.encodeWithSelector( + AllocatorLib.InvalidBalanceForAdditionalCommitments.selector, + existingBalance, // available balance + additionalCommitment // requested additional commitment + ) + ); + allocator.permit2Allocation( + arbiter, + user, + defaultExpiration, + permitted, + additionalCommitmentAmounts, + details, + claimHash, + '', + bytes32(0), + signature, + '' + ); + } + + /// @notice Test permit2Allocation reverts when additionalCommitmentAmounts exceed unallocated balance + function test_permit2Allocation_revert_insufficientUnallocatedBalance() public { + uint256 existingBalance = defaultAmount; + uint256 newDeposit = defaultAmount; + uint256 additionalCommitment = existingBalance; // Use full existing balance + + // First, deposit tokens directly to the user (existing balance) + usdc.mint(user, existingBalance); + vm.startPrank(user); + usdc.approve(address(compact), existingBalance); + bytes12 lockTag = _getLockTag(); + uint256 id = compact.depositERC20(address(usdc), lockTag, existingBalance, user); + vm.stopPrank(); + + // Allocate the existing balance first + Lock[] memory firstCommitments = new Lock[](1); + firstCommitments[0] = Lock({lockTag: lockTag, token: address(usdc), amount: 1}); // allocate a single token + vm.prank(user); + allocator.allocate(firstCommitments, arbiter, defaultExpiration, BATCH_COMPACT_TYPEHASH, bytes32(0)); + + // Now try permit2 allocation with additionalCommitment from already-allocated balance + uint88 freeNonce = 1; + uint256 nonce = _createPermit2Nonce(user, freeNonce); + + // Mint additional tokens for permit2 deposit + usdc.mint(user, newDeposit); + + // Prepare token permissions for permit2 + ISignatureTransfer.TokenPermissions[] memory permitted = _createTokenPermissions(address(usdc), newDeposit); + + // Prepare deposit details + DepositDetails memory details = _createDepositDetails(nonce, defaultExpiration, lockTag); + + // Compute claimHash + bytes32[] memory commitmentHashes = new bytes32[](1); + commitmentHashes[0] = _computeCommitmentHash(id, newDeposit + additionalCommitment); + + bytes32 claimHash = keccak256( + abi.encode( + BATCH_COMPACT_TYPEHASH, + arbiter, + user, + nonce, + defaultExpiration, + keccak256(abi.encodePacked(commitmentHashes)) + ) + ); + + // Create Permit2 signature + bytes memory signature = _createPermit2Signature(permitted, details, claimHash, userPK); + + // Create additional commitment amounts array + uint256[] memory additionalCommitmentAmounts = new uint256[](1); + additionalCommitmentAmounts[0] = additionalCommitment; + + // Execute permit2Allocation should revert due to insufficient unallocated balance + // The required amount is: first allocation (1 token) + additionalCommitment (1e18) = 1e18 + 1 + vm.expectRevert( + abi.encodeWithSelector( + IOnChainAllocator.InsufficientBalance.selector, + user, + id, + existingBalance, // previousBalance (before permit2 deposit) + 1 + additionalCommitment // required (first allocation of 1 + additionalCommitment) + ) + ); + allocator.permit2Allocation( + arbiter, + user, + defaultExpiration, + permitted, + additionalCommitmentAmounts, + details, + claimHash, + '', + bytes32(0), + signature, + '' + ); + } + + /// @notice Test additionalCommitmentAmounts array length must match idsAndAmounts length + function test_executeAllocation_revert_invalidAdditionalCommitmentsLength() public { + uint256[2][] memory idsAndAmounts = _idsAndAmountsFor(address(usdc), defaultAmount); + + // Create additionalCommitmentAmounts with wrong length + uint256[] memory additionalCommitmentAmounts = new uint256[](2); // Should be 1 + + vm.expectRevert(abi.encodeWithSelector(AllocatorLib.InvalidAdditionalCommitmentsLength.selector, 2, 1)); + allocator.prepareAllocation( + recipient, + idsAndAmounts, + additionalCommitmentAmounts, + arbiter, + defaultExpiration, + BATCH_COMPACT_TYPEHASH, + bytes32(0), + '' + ); + } } /* ============================================================================ @@ -2846,6 +3439,7 @@ contract MaliciousRecipient is IERC1271 { ALLOCATOR.prepareAllocation( reentrantRecipient, reentrantIdsAndAmounts, + new uint256[](reentrantIdsAndAmounts.length), reentrantArbiter, reentrantExpires, reentrantTypehash, @@ -2858,6 +3452,7 @@ contract MaliciousRecipient is IERC1271 { ALLOCATOR.executeAllocation( reentrantRecipient, reentrantIdsAndAmounts, + new uint256[](reentrantIdsAndAmounts.length), reentrantArbiter, reentrantExpires, reentrantTypehash,