diff --git a/snapshots/AllowancePositionManager.Operations.json b/snapshots/AllowancePositionManager.Operations.json index f39488f87..a2d78bb70 100644 --- a/snapshots/AllowancePositionManager.Operations.json +++ b/snapshots/AllowancePositionManager.Operations.json @@ -1,11 +1,16 @@ { "approveWithdraw": "49895", "approveWithdrawWithSig": "66560", - "borrowOnBehalfOf": "309435", + "borrowOnBehalfOf": "310092", + "borrowOnBehalfOf (with temporary allowance)": "247979", "delegateCredit": "49864", "delegateCreditWithSig": "66505", "renounceCreditDelegation": "28020", "renounceWithdrawAllowance": "28007", - "withdrawOnBehalfOf: full": "121329", - "withdrawOnBehalfOf: partial": "131461" + "temporaryApproveWithdraw": "25552", + "temporaryDelegateCredit": "25528", + "withdrawOnBehalfOf: full": "121837", + "withdrawOnBehalfOf: full (with temporary allowance)": "56154", + "withdrawOnBehalfOf: partial": "132096", + "withdrawOnBehalfOf: partial (with temporary allowance)": "66954" } \ No newline at end of file diff --git a/src/position-manager/AllowancePositionManager.sol b/src/position-manager/AllowancePositionManager.sol index c895be719..da4242c39 100644 --- a/src/position-manager/AllowancePositionManager.sol +++ b/src/position-manager/AllowancePositionManager.sol @@ -4,6 +4,8 @@ pragma solidity 0.8.28; import {SignatureChecker} from 'src/dependencies/openzeppelin/SignatureChecker.sol'; import {SafeERC20, IERC20} from 'src/dependencies/openzeppelin/SafeERC20.sol'; +import {SlotDerivation} from 'src/dependencies/openzeppelin/SlotDerivation.sol'; +import {TransientSlot} from 'src/dependencies/openzeppelin/TransientSlot.sol'; import {EIP712} from 'src/dependencies/solady/EIP712.sol'; import {MathUtils} from 'src/libraries/math/MathUtils.sol'; import {NoncesKeyed} from 'src/utils/NoncesKeyed.sol'; @@ -23,8 +25,20 @@ contract AllowancePositionManager is { using SafeERC20 for IERC20; using MathUtils for uint256; + using SlotDerivation for bytes32; + using TransientSlot for *; using EIP712Hash for *; + /// @notice Slot for the temporary withdraw allowances. + /// @dev keccak256(abi.encode(uint256(keccak256("aave.transient.WITHDRAW_ALLOWANCES")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant _TEMPORARY_WITHDRAW_ALLOWANCES_SLOT = + 0x4b5553e643854b1bacc0d454fec49da235a0faac2caff4f059541ccf9f154700; + + /// @notice Slot for the temporary credit delegations. + /// @dev keccak256(abi.encode(uint256(keccak256("aave.transient.CREDIT_DELEGATIONS")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant _TEMPORARY_CREDIT_DELEGATIONS_SLOT = + 0x5aa827cbd079fec1557555542f5232f82e413903ea6ea8e935f719e23b7c4a00; + mapping(address spoke => mapping(uint256 reserveId => mapping(address owner => mapping(address spender => uint256 amount)))) private _withdrawAllowances; @@ -73,6 +87,21 @@ contract AllowancePositionManager is }); } + /// @inheritdoc IAllowancePositionManager + function temporaryApproveWithdraw( + address spoke, + uint256 reserveId, + address spender, + uint256 amount + ) external onlyRegisteredSpoke(spoke) { + _temporaryWithdrawAllowancesSlot({ + spoke: spoke, + reserveId: reserveId, + owner: msg.sender, + spender: spender + }).tstore(amount); + } + /// @inheritdoc IAllowancePositionManager function delegateCredit( address spoke, @@ -111,6 +140,21 @@ contract AllowancePositionManager is }); } + /// @inheritdoc IAllowancePositionManager + function temporaryDelegateCredit( + address spoke, + uint256 reserveId, + address spender, + uint256 amount + ) external onlyRegisteredSpoke(spoke) { + _temporaryDelegateCreditsSlot({ + spoke: spoke, + reserveId: reserveId, + owner: msg.sender, + spender: spender + }).tstore(amount); + } + /// @inheritdoc IAllowancePositionManager function renounceWithdrawAllowance( address spoke, @@ -256,6 +300,7 @@ contract AllowancePositionManager is emit CreditDelegation(spoke, reserveId, owner, spender, newCreditDelegation); } + /// @dev Temporary allowance takes precedence over stored allowance, and does not cumulate. function _spendWithdrawAllowance( address spoke, uint256 reserveId, @@ -263,13 +308,35 @@ contract AllowancePositionManager is address spender, uint256 amount ) internal { - uint256 currentAllowance = _withdrawAllowances[spoke][reserveId][owner][spender]; - require(currentAllowance >= amount, InsufficientWithdrawAllowance(currentAllowance, amount)); - if (currentAllowance != type(uint256).max) { - _withdrawAllowances[spoke][reserveId][owner][spender] = currentAllowance.uncheckedSub(amount); + uint256 temporaryAllowance = _temporaryWithdrawAllowancesSlot({ + spoke: spoke, + reserveId: reserveId, + owner: owner, + spender: spender + }).tload(); + if (temporaryAllowance > 0) { + require( + temporaryAllowance >= amount, + InsufficientTemporaryWithdrawAllowance(temporaryAllowance, amount) + ); + if (temporaryAllowance != type(uint256).max) { + _temporaryWithdrawAllowancesSlot({ + spoke: spoke, + reserveId: reserveId, + owner: owner, + spender: spender + }).tstore(temporaryAllowance.uncheckedSub(amount)); + } + } else { + uint256 allowance = _withdrawAllowances[spoke][reserveId][owner][spender]; + require(allowance >= amount, InsufficientWithdrawAllowance(allowance, amount)); + if (allowance != type(uint256).max) { + _withdrawAllowances[spoke][reserveId][owner][spender] = allowance.uncheckedSub(amount); + } } } + /// @dev Temporary allowance takes precedence over stored allowance, and does not cumulate. function _spendCreditDelegation( address spoke, uint256 reserveId, @@ -277,13 +344,64 @@ contract AllowancePositionManager is address spender, uint256 amount ) internal { - uint256 currentAllowance = _creditDelegations[spoke][reserveId][owner][spender]; - require(currentAllowance >= amount, InsufficientCreditDelegation(currentAllowance, amount)); - if (currentAllowance != type(uint256).max) { - _creditDelegations[spoke][reserveId][owner][spender] = currentAllowance.uncheckedSub(amount); + uint256 temporaryAllowance = _temporaryDelegateCreditsSlot({ + spoke: spoke, + reserveId: reserveId, + owner: owner, + spender: spender + }).tload(); + if (temporaryAllowance > 0) { + require( + temporaryAllowance >= amount, + InsufficientTemporaryCreditDelegation(temporaryAllowance, amount) + ); + if (temporaryAllowance != type(uint256).max) { + _temporaryDelegateCreditsSlot({ + spoke: spoke, + reserveId: reserveId, + owner: owner, + spender: spender + }).tstore(temporaryAllowance.uncheckedSub(amount)); + } + } else { + uint256 allowance = _creditDelegations[spoke][reserveId][owner][spender]; + require(allowance >= amount, InsufficientCreditDelegation(allowance, amount)); + if (allowance != type(uint256).max) { + _creditDelegations[spoke][reserveId][owner][spender] = allowance.uncheckedSub(amount); + } } } + function _temporaryWithdrawAllowancesSlot( + address spoke, + uint256 reserveId, + address owner, + address spender + ) internal pure returns (TransientSlot.Uint256Slot) { + return + _TEMPORARY_WITHDRAW_ALLOWANCES_SLOT + .deriveMapping(spoke) + .deriveMapping(reserveId) + .deriveMapping(owner) + .deriveMapping(spender) + .asUint256(); + } + + function _temporaryDelegateCreditsSlot( + address spoke, + uint256 reserveId, + address owner, + address spender + ) internal pure returns (TransientSlot.Uint256Slot) { + return + _TEMPORARY_CREDIT_DELEGATIONS_SLOT + .deriveMapping(spoke) + .deriveMapping(reserveId) + .deriveMapping(owner) + .deriveMapping(spender) + .asUint256(); + } + function _domainNameAndVersion() internal pure override returns (string memory, string memory) { return ('AllowancePositionManager', '1'); } diff --git a/src/position-manager/interfaces/IAllowancePositionManager.sol b/src/position-manager/interfaces/IAllowancePositionManager.sol index fe1a3e0c0..5aaf75fff 100644 --- a/src/position-manager/interfaces/IAllowancePositionManager.sol +++ b/src/position-manager/interfaces/IAllowancePositionManager.sol @@ -11,8 +11,12 @@ import {IPositionManagerBase} from 'src/position-manager/interfaces/IPositionMan interface IAllowancePositionManager is IPositionManagerBase { /// @notice Thrown when the withdraw allowance is insufficient. error InsufficientWithdrawAllowance(uint256 allowance, uint256 required); + /// @notice Thrown when the temporary withdraw allowance is insufficient. + error InsufficientTemporaryWithdrawAllowance(uint256 allowance, uint256 required); /// @notice Thrown when the credit delegation allowance is insufficient. error InsufficientCreditDelegation(uint256 allowance, uint256 required); + /// @notice Thrown when the temporary credit delegation allowance is insufficient. + error InsufficientTemporaryCreditDelegation(uint256 allowance, uint256 required); /// @notice Emitted when owner approves spender to withdraw amount for reserveId on their behalf. /// @param spoke The address of the spoke. @@ -62,6 +66,20 @@ interface IAllowancePositionManager is IPositionManagerBase { bytes calldata signature ) external; + /// @notice Temporarily approves a spender to withdraw assets from the specified reserve on the spoke. + /// @dev Temporary allowance takes precedence over stored allowance, and does not cumulate. + /// @dev The allowance is discarded after the transaction. + /// @param spoke The address of the spoke. + /// @param reserveId The identifier of the reserve. + /// @param spender The address of the spender to receive the allowance. + /// @param amount The amount of allowance. + function temporaryApproveWithdraw( + address spoke, + uint256 reserveId, + address spender, + uint256 amount + ) external; + /// @notice Approves a credit delegation allowance for a spender. /// @param spoke The address of the spoke. /// @param reserveId The identifier of the reserve. @@ -82,6 +100,20 @@ interface IAllowancePositionManager is IPositionManagerBase { bytes calldata signature ) external; + /// @notice Temporarily approves a credit delegation allowance for a spender. + /// @dev Temporary allowance takes precedence over stored allowance, and does not cumulate. + /// @dev The allowance is discarded after the transaction. + /// @param spoke The address of the spoke. + /// @param reserveId The identifier of the reserve. + /// @param spender The address of the spender to receive the allowance. + /// @param amount The amount of allowance. + function temporaryDelegateCredit( + address spoke, + uint256 reserveId, + address spender, + uint256 amount + ) external; + /// @notice Renounces the withdraw allowance given by the owner. /// @param spoke The address of the spoke. /// @param reserveId The identifier of the reserve. @@ -95,7 +127,8 @@ interface IAllowancePositionManager is IPositionManagerBase { function renounceCreditDelegation(address spoke, uint256 reserveId, address owner) external; /// @notice Executes a withdraw on behalf of a user. - /// @dev The caller must have sufficient withdraw allowance from onBehalfOf. + /// @dev The caller must have sufficient withdraw allowance from `onBehalfOf`. + /// @dev Temporary allowance takes precedence over stored allowance, and does not cumulate. /// @dev The caller receives the withdrawn assets. /// @param spoke The address of the spoke. /// @param reserveId The identifier of the reserve. @@ -111,7 +144,8 @@ interface IAllowancePositionManager is IPositionManagerBase { ) external returns (uint256, uint256); /// @notice Executes a borrow on behalf of a user. - /// @dev The caller must have sufficient credit delegation allowance from onBehalfOf. + /// @dev The caller must have sufficient credit delegation allowance from `onBehalfOf`. + /// @dev Temporary allowance takes precedence over stored allowance, and does not cumulate. /// @dev The caller receives the borrowed assets. /// @param spoke The address of the spoke. /// @param reserveId The identifier of the reserve. diff --git a/tests/gas/PositionManagers.Operations.gas.t.sol b/tests/gas/PositionManagers.Operations.gas.t.sol index 080a9d7b9..ed7253786 100644 --- a/tests/gas/PositionManagers.Operations.gas.t.sol +++ b/tests/gas/PositionManagers.Operations.gas.t.sol @@ -142,6 +142,38 @@ contract AllowancePositionManager_Gas_Tests is SpokeBase { vm.snapshotGasLastCall(NAMESPACE, 'withdrawOnBehalfOf: full'); } + /// forge-config: default.isolate = false + function test_withdrawOnBehalfOf_WithTemporaryWithdrawAllowance() public { + uint256 amount = 100e18; + + vm.prank(alice); + positionManager.temporaryApproveWithdraw( + address(spoke1), + _daiReserveId(spoke1), + bob, + UINT256_MAX + ); + + Utils.supply(spoke1, _daiReserveId(spoke1), alice, mintAmount_DAI, alice); + Utils.withdraw(spoke1, _daiReserveId(spoke1), alice, amount, alice); + + vm.prank(bob); + positionManager.withdrawOnBehalfOf(address(spoke1), _daiReserveId(spoke1), amount, alice); + vm.snapshotGasLastCall(NAMESPACE, 'withdrawOnBehalfOf: partial (with temporary allowance)'); + + vm.prank(alice); + positionManager.temporaryApproveWithdraw( + address(spoke1), + _daiReserveId(spoke1), + bob, + UINT256_MAX + ); + + vm.prank(bob); + positionManager.withdrawOnBehalfOf(address(spoke1), _daiReserveId(spoke1), UINT256_MAX, alice); + vm.snapshotGasLastCall(NAMESPACE, 'withdrawOnBehalfOf: full (with temporary allowance)'); + } + function test_borrowOnBehalfOf() public { uint256 aliceSupplyAmount = 5000e18; uint256 bobSupplyAmount = 1000e18; @@ -158,6 +190,28 @@ contract AllowancePositionManager_Gas_Tests is SpokeBase { vm.snapshotGasLastCall(NAMESPACE, 'borrowOnBehalfOf'); } + /// forge-config: default.isolate = false + function test_borrowOnBehalfOf_WithTemporaryDelegateCredit() public { + uint256 aliceSupplyAmount = 5000e18; + uint256 bobSupplyAmount = 1000e18; + uint256 borrowAmount = 750e18; + + vm.prank(alice); + positionManager.temporaryDelegateCredit( + address(spoke1), + _daiReserveId(spoke1), + bob, + borrowAmount + ); + + Utils.supplyCollateral(spoke1, _daiReserveId(spoke1), alice, aliceSupplyAmount, alice); + Utils.supplyCollateral(spoke1, _daiReserveId(spoke1), bob, bobSupplyAmount, bob); + + vm.prank(bob); + positionManager.borrowOnBehalfOf(address(spoke1), _daiReserveId(spoke1), borrowAmount, alice); + vm.snapshotGasLastCall(NAMESPACE, 'borrowOnBehalfOf (with temporary allowance)'); + } + function test_approveWithdraw() public { uint256 amount = 100e18; @@ -190,6 +244,14 @@ contract AllowancePositionManager_Gas_Tests is SpokeBase { vm.snapshotGasLastCall(NAMESPACE, 'approveWithdrawWithSig'); } + function test_temporaryApproveWithdraw() public { + uint256 amount = 100e18; + + vm.prank(alice); + positionManager.temporaryApproveWithdraw(address(spoke1), _daiReserveId(spoke1), bob, amount); + vm.snapshotGasLastCall(NAMESPACE, 'temporaryApproveWithdraw'); + } + function test_renounceWithdrawAllowance() public { uint256 amount = 100e18; @@ -233,6 +295,14 @@ contract AllowancePositionManager_Gas_Tests is SpokeBase { vm.snapshotGasLastCall(NAMESPACE, 'delegateCreditWithSig'); } + function test_temporaryDelegateCredit() public { + uint256 amount = 100e18; + + vm.prank(alice); + positionManager.temporaryDelegateCredit(address(spoke1), _daiReserveId(spoke1), bob, amount); + vm.snapshotGasLastCall(NAMESPACE, 'temporaryDelegateCredit'); + } + function test_renounceCreditDelegation() public { uint256 amount = 100e18; diff --git a/tests/mocks/AllowancePositionManagerWrapper.sol b/tests/mocks/AllowancePositionManagerWrapper.sol new file mode 100644 index 000000000..6c66e9cfb --- /dev/null +++ b/tests/mocks/AllowancePositionManagerWrapper.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import {TransientSlot} from 'src/dependencies/openzeppelin/TransientSlot.sol'; +import {AllowancePositionManager} from 'src/position-manager/AllowancePositionManager.sol'; + +contract AllowancePositionManagerWrapper is AllowancePositionManager { + using TransientSlot for *; + + constructor(address spoke_) AllowancePositionManager(spoke_) {} + + function temporaryWithdrawAllowance( + address spoke, + uint256 reserveId, + address owner, + address spender + ) external view returns (uint256) { + return + _temporaryWithdrawAllowancesSlot({ + spoke: spoke, + reserveId: reserveId, + owner: owner, + spender: spender + }).tload(); + } + + function temporaryCreditDelegation( + address spoke, + uint256 reserveId, + address owner, + address spender + ) external view returns (uint256) { + return + _temporaryDelegateCreditsSlot({ + spoke: spoke, + reserveId: reserveId, + owner: owner, + spender: spender + }).tload(); + } +} diff --git a/tests/unit/position-managers/AllowancePositionManager.t.sol b/tests/unit/position-managers/AllowancePositionManager.t.sol index 3e3ab655b..7d3d38357 100644 --- a/tests/unit/position-managers/AllowancePositionManager.t.sol +++ b/tests/unit/position-managers/AllowancePositionManager.t.sol @@ -2,10 +2,11 @@ // Copyright (c) 2025 Aave Labs pragma solidity ^0.8.0; +import {AllowancePositionManagerWrapper} from 'tests/mocks/AllowancePositionManagerWrapper.sol'; import 'tests/unit/Spoke/SpokeBase.t.sol'; contract AllowancePositionManagerTest is SpokeBase { - AllowancePositionManager public positionManager; + AllowancePositionManagerWrapper public positionManager; TestReturnValues public returnValues; uint256 public alicePk; @@ -13,7 +14,7 @@ contract AllowancePositionManagerTest is SpokeBase { super.setUp(); (alice, alicePk) = makeAddrAndKey('alice'); - positionManager = new AllowancePositionManager(address(ADMIN)); + positionManager = new AllowancePositionManagerWrapper(ADMIN); vm.prank(SPOKE_ADMIN); spoke1.updatePositionManager(address(positionManager), true); @@ -79,7 +80,7 @@ contract AllowancePositionManagerTest is SpokeBase { function test_approveWithdraw_fuzz(address spender, uint256 reserveId, uint256 amount) public { vm.assume(spender != address(0)); reserveId = bound(reserveId, 0, spoke1.getReserveCount() - 1); - amount = bound(amount, 1, mintAmount_DAI); + amount = bound(amount, 1, MAX_SUPPLY_AMOUNT); vm.expectEmit(address(positionManager)); emit IAllowancePositionManager.WithdrawApproval( @@ -102,7 +103,7 @@ contract AllowancePositionManagerTest is SpokeBase { ) public { vm.assume(spender != address(0)); reserveId = bound(reserveId, 0, spoke1.getReserveCount() - 1); - amount = bound(amount, 1, mintAmount_DAI); + amount = bound(amount, 1, MAX_SUPPLY_AMOUNT); EIP712Types.WithdrawPermit memory p = _withdrawPermitData( spender, @@ -158,7 +159,7 @@ contract AllowancePositionManagerTest is SpokeBase { positionManager.approveWithdrawWithSig(p, signature); } - function test_approveWithdrawWithSig_revertsWith_InvalidAccountNonce(bytes32) public { + function test_approveWithdrawWithSig_fuzz_revertsWith_InvalidAccountNonce(bytes32) public { EIP712Types.WithdrawPermit memory p = _withdrawPermitData( vm.randomAddress(), alice, @@ -198,9 +199,50 @@ contract AllowancePositionManagerTest is SpokeBase { positionManager.approveWithdrawWithSig(p, signature); } + function test_temporaryApproveWithdraw_fuzz( + address spender, + uint256 reserveId, + uint256 amount + ) public { + vm.assume(spender != address(0)); + reserveId = bound(reserveId, 0, spoke1.getReserveCount() - 1); + amount = bound(amount, 1, MAX_SUPPLY_AMOUNT); + + vm.expectEmit(address(positionManager), 0); + vm.prank(alice); + positionManager.temporaryApproveWithdraw(address(spoke1), reserveId, spender, amount); + + assertEq( + positionManager.temporaryWithdrawAllowance(address(spoke1), reserveId, alice, spender), + amount + ); + } + + /// forge-config: default.isolate = true + function test_temporaryApproveWithdraw_TransientStorage() public { + // make sure transient storage is used for temporary withdraw allowances + vm.prank(alice); + positionManager.temporaryApproveWithdraw(address(spoke1), _daiReserveId(spoke1), bob, 100e18); + assertEq( + positionManager.temporaryWithdrawAllowance( + address(spoke1), + _daiReserveId(spoke1), + alice, + bob + ), + 0 + ); + } + + function test_temporaryApproveWithdraw_revertsWith_SpokeNotRegistered() public { + vm.expectRevert(IPositionManagerBase.SpokeNotRegistered.selector); + vm.prank(alice); + positionManager.temporaryApproveWithdraw(address(spoke2), 1, bob, 100e18); + } + function test_renounceWithdrawAllowance_fuzz(uint256 initialAllowance) public { uint256 reserveId = _randomReserveId(spoke1); - initialAllowance = bound(initialAllowance, 1, mintAmount_DAI); + initialAllowance = bound(initialAllowance, 1, MAX_SUPPLY_AMOUNT); vm.prank(alice); positionManager.approveWithdraw(address(spoke1), reserveId, bob, initialAllowance); @@ -234,10 +276,29 @@ contract AllowancePositionManagerTest is SpokeBase { } function test_withdrawOnBehalfOf() public { - test_withdrawOnBehalfOf_fuzz(100e18); + test_withdrawOnBehalfOf_fuzz(100e18, 0); } - function test_withdrawOnBehalfOf_fuzz(uint256 amount) public { + function test_withdrawOnBehalfOf_TemporaryWithdrawAllowanceTakesPrecedence() public { + uint256 storedAllowance = 300e18; + _fuzzyApproveWithdraw( + alice, + alicePk, + bob, + address(spoke1), + _daiReserveId(spoke1), + storedAllowance, + 0 + ); + test_withdrawOnBehalfOf_fuzz(100e18, 2); + // this check is also performed in test_withdrawOnBehalfOf_fuzz, duplicating in case of future changes + assertEq( + positionManager.withdrawAllowance(address(spoke1), _daiReserveId(spoke1), alice, bob), + storedAllowance + ); + } + + function test_withdrawOnBehalfOf_fuzz(uint256 amount, uint256 approvalType) public { amount = bound(amount, 1, mintAmount_DAI); Utils.supply({ @@ -249,8 +310,28 @@ contract AllowancePositionManagerTest is SpokeBase { }); uint256 expectedSupplyShares = hub1.previewAddByAssets(daiAssetId, mintAmount_DAI); - vm.prank(alice); - positionManager.approveWithdraw(address(spoke1), _daiReserveId(spoke1), bob, amount); + approvalType = _fuzzyApproveWithdraw( + alice, + alicePk, + bob, + address(spoke1), + _daiReserveId(spoke1), + amount, + approvalType + ); + + uint256 allowanceBefore = positionManager.withdrawAllowance( + address(spoke1), + _daiReserveId(spoke1), + alice, + bob + ); + uint256 temporaryAllowanceBefore = positionManager.temporaryWithdrawAllowance( + address(spoke1), + _daiReserveId(spoke1), + alice, + bob + ); uint256 userBalanceBefore = tokenList.dai.balanceOf(alice); uint256 callerBalanceBefore = tokenList.dai.balanceOf(bob); @@ -289,11 +370,24 @@ contract AllowancePositionManagerTest is SpokeBase { assertEq(tokenList.dai.allowance(address(positionManager), address(hub1)), 0); assertEq( positionManager.withdrawAllowance(address(spoke1), _daiReserveId(spoke1), alice, bob), - 0 + (approvalType < 2) ? 0 : allowanceBefore + ); + assertEq( + positionManager.temporaryWithdrawAllowance( + address(spoke1), + _daiReserveId(spoke1), + alice, + bob + ), + (approvalType == 2) ? 0 : temporaryAllowanceBefore ); } - function test_withdrawOnBehalfOf_fuzz_allBalance(uint256 supplyAmount) public { + // consume partial allowance + function test_withdrawOnBehalfOf_fuzz_allBalance( + uint256 supplyAmount, + uint256 approvalType + ) public { supplyAmount = bound(supplyAmount, 1, mintAmount_DAI); Utils.supply({ @@ -305,8 +399,15 @@ contract AllowancePositionManagerTest is SpokeBase { }); uint256 expectedSupplyShares = hub1.previewAddByAssets(daiAssetId, supplyAmount); - vm.prank(alice); - positionManager.approveWithdraw(address(spoke1), _daiReserveId(spoke1), bob, supplyAmount * 10); + approvalType = _fuzzyApproveWithdraw( + alice, + alicePk, + bob, + address(spoke1), + _daiReserveId(spoke1), + supplyAmount * 10, + approvalType + ); uint256 userBalanceBefore = tokenList.dai.balanceOf(alice); uint256 callerBalanceBefore = tokenList.dai.balanceOf(bob); @@ -317,6 +418,12 @@ contract AllowancePositionManagerTest is SpokeBase { alice, bob ); + uint256 temporaryAllowanceBefore = positionManager.temporaryWithdrawAllowance( + address(spoke1), + _daiReserveId(spoke1), + alice, + bob + ); assertEq(spoke1.getUserSuppliedShares(_daiReserveId(spoke1), alice), expectedSupplyShares); @@ -347,12 +454,22 @@ contract AllowancePositionManagerTest is SpokeBase { assertEq(tokenList.dai.allowance(address(positionManager), address(hub1)), 0); assertEq( positionManager.withdrawAllowance(address(spoke1), _daiReserveId(spoke1), alice, bob), - allowanceBefore - (supplyAmount * 2) + (approvalType < 2) ? allowanceBefore - (supplyAmount * 2) : allowanceBefore + ); + assertEq( + positionManager.temporaryWithdrawAllowance( + address(spoke1), + _daiReserveId(spoke1), + alice, + bob + ), + (approvalType == 2) ? temporaryAllowanceBefore - (supplyAmount * 2) : temporaryAllowanceBefore ); } - function test_withdrawOnBehalfOf_fuzz_allBalance_noAllowanceDecreased( - uint256 supplyAmount + function test_withdrawOnBehalfOf_fuzz_allBalance_noAllowanceDecrease( + uint256 supplyAmount, + uint256 approvalType ) public { supplyAmount = bound(supplyAmount, 1, mintAmount_DAI); @@ -365,8 +482,15 @@ contract AllowancePositionManagerTest is SpokeBase { }); uint256 expectedSupplyShares = hub1.previewAddByAssets(daiAssetId, supplyAmount); - vm.prank(alice); - positionManager.approveWithdraw(address(spoke1), _daiReserveId(spoke1), bob, type(uint256).max); + approvalType = _fuzzyApproveWithdraw( + alice, + alicePk, + bob, + address(spoke1), + _daiReserveId(spoke1), + type(uint256).max, + approvalType + ); uint256 userBalanceBefore = tokenList.dai.balanceOf(alice); uint256 callerBalanceBefore = tokenList.dai.balanceOf(bob); @@ -401,13 +525,24 @@ contract AllowancePositionManagerTest is SpokeBase { assertEq(tokenList.dai.allowance(address(positionManager), address(hub1)), 0); assertEq( positionManager.withdrawAllowance(address(spoke1), _daiReserveId(spoke1), alice, bob), - type(uint256).max + (approvalType < 2) ? type(uint256).max : 0 + ); + assertEq( + positionManager.temporaryWithdrawAllowance( + address(spoke1), + _daiReserveId(spoke1), + alice, + bob + ), + (approvalType == 2) ? type(uint256).max : 0 ); } + // consume all allowance function test_withdrawOnBehalfOf_fuzz_allBalanceWithInterest( uint256 supplyAmount, - uint256 borrowAmount + uint256 borrowAmount, + uint256 approvalType ) public { supplyAmount = bound(supplyAmount, 2, mintAmount_DAI / 2); borrowAmount = bound(borrowAmount, 1, supplyAmount / 2); @@ -451,8 +586,15 @@ contract AllowancePositionManagerTest is SpokeBase { uint256 expectedWithdrawAmount = spoke1.getUserSuppliedAssets(_daiReserveId(spoke1), alice); - vm.prank(alice); - positionManager.approveWithdraw(address(spoke1), _daiReserveId(spoke1), bob, supplyAmount * 10); + _fuzzyApproveWithdraw( + alice, + alicePk, + bob, + address(spoke1), + _daiReserveId(spoke1), + supplyAmount * 10, + approvalType + ); uint256 userBalanceBefore = tokenList.dai.balanceOf(alice); uint256 callerBalanceBefore = tokenList.dai.balanceOf(bob); @@ -489,10 +631,61 @@ contract AllowancePositionManagerTest is SpokeBase { positionManager.withdrawAllowance(address(spoke1), _daiReserveId(spoke1), alice, bob), 0 ); + assertEq( + positionManager.temporaryWithdrawAllowance( + address(spoke1), + _daiReserveId(spoke1), + alice, + bob + ), + 0 + ); + } + + // temporary withdraw allowance takes precedence over stored withdraw allowance, and does not cumulate + function test_withdrawOnBehalfOf_revertsWith_InsufficientTemporaryWithdrawAllowance() public { + uint256 storedAllowance = 300e18; + _fuzzyApproveWithdraw( + alice, + alicePk, + bob, + address(spoke1), + _daiReserveId(spoke1), + storedAllowance, + 0 + ); + + uint256 amount = 20e18; + uint256 temporaryAllowance = amount - 1; + _fuzzyApproveWithdraw( + alice, + alicePk, + bob, + address(spoke1), + _daiReserveId(spoke1), + temporaryAllowance, + 2 + ); + + vm.expectRevert( + abi.encodeWithSelector( + IAllowancePositionManager.InsufficientTemporaryWithdrawAllowance.selector, + temporaryAllowance, + amount + ) + ); + vm.prank(bob); + positionManager.withdrawOnBehalfOf(address(spoke1), _daiReserveId(spoke1), amount, alice); + + assertEq( + positionManager.withdrawAllowance(address(spoke1), _daiReserveId(spoke1), alice, bob), + storedAllowance + ); } - function test_withdrawOnBehalfOf_revertsWith_InsufficientWithdrawAllowance( - uint256 approvalAmount + function test_withdrawOnBehalfOf_fuzz_revertsWith_InsufficientAllowance( + uint256 approvalAmount, + uint256 approvalType ) public { uint256 amount = 100e18; approvalAmount = bound(approvalAmount, 1, amount - 1); @@ -505,12 +698,21 @@ contract AllowancePositionManagerTest is SpokeBase { onBehalfOf: alice }); - vm.prank(alice); - positionManager.approveWithdraw(address(spoke1), _daiReserveId(spoke1), bob, approvalAmount); + approvalType = _fuzzyApproveWithdraw( + alice, + alicePk, + bob, + address(spoke1), + _daiReserveId(spoke1), + approvalAmount, + approvalType + ); vm.expectRevert( abi.encodeWithSelector( - IAllowancePositionManager.InsufficientWithdrawAllowance.selector, + (approvalType == 2) + ? IAllowancePositionManager.InsufficientTemporaryWithdrawAllowance.selector + : IAllowancePositionManager.InsufficientWithdrawAllowance.selector, approvalAmount, amount ) @@ -549,7 +751,7 @@ contract AllowancePositionManagerTest is SpokeBase { function test_creditDelegation_fuzz(address spender, uint256 reserveId, uint256 amount) public { vm.assume(spender != address(0)); reserveId = bound(reserveId, 0, spoke1.getReserveCount() - 1); - amount = bound(amount, 1, mintAmount_DAI); + amount = bound(amount, 1, MAX_SUPPLY_AMOUNT); vm.expectEmit(address(positionManager)); emit IAllowancePositionManager.CreditDelegation( @@ -572,7 +774,7 @@ contract AllowancePositionManagerTest is SpokeBase { ) public { vm.assume(spender != address(0)); reserveId = bound(reserveId, 0, spoke1.getReserveCount() - 1); - amount = bound(amount, 1, mintAmount_DAI); + amount = bound(amount, 1, MAX_SUPPLY_AMOUNT); EIP712Types.CreditDelegation memory p = _creditDelegationData( spender, @@ -670,9 +872,45 @@ contract AllowancePositionManagerTest is SpokeBase { positionManager.delegateCreditWithSig(p, signature); } + function test_temporaryDelegateCredit_fuzz( + address spender, + uint256 reserveId, + uint256 amount + ) public { + vm.assume(spender != address(0)); + reserveId = bound(reserveId, 0, spoke1.getReserveCount() - 1); + amount = bound(amount, 1, MAX_SUPPLY_AMOUNT); + + vm.expectEmit(address(positionManager), 0); + vm.prank(alice); + positionManager.temporaryDelegateCredit(address(spoke1), reserveId, spender, amount); + + assertEq( + positionManager.temporaryCreditDelegation(address(spoke1), reserveId, alice, spender), + amount + ); + } + + /// forge-config: default.isolate = true + function test_temporaryDelegateCredit_TransientStorage() public { + // make sure transient storage is used for temporary credit delegations + vm.prank(alice); + positionManager.temporaryDelegateCredit(address(spoke1), _daiReserveId(spoke1), bob, 100e18); + assertEq( + positionManager.temporaryCreditDelegation(address(spoke1), _daiReserveId(spoke1), alice, bob), + 0 + ); + } + + function test_temporaryDelegateCredit_revertsWith_SpokeNotRegistered() public { + vm.expectRevert(IPositionManagerBase.SpokeNotRegistered.selector); + vm.prank(alice); + positionManager.temporaryDelegateCredit(address(spoke2), 1, bob, 100e18); + } + function test_renounceCreditDelegation_fuzz(uint256 initialAllowance) public { uint256 reserveId = _randomReserveId(spoke1); - initialAllowance = bound(initialAllowance, 1, mintAmount_DAI); + initialAllowance = bound(initialAllowance, 1, MAX_SUPPLY_AMOUNT); vm.prank(alice); positionManager.delegateCredit(address(spoke1), reserveId, bob, initialAllowance); @@ -706,10 +944,33 @@ contract AllowancePositionManagerTest is SpokeBase { } function test_borrowOnBehalfOf() public { - test_borrowOnBehalfOf_fuzz(5e18, 5e18); + test_borrowOnBehalfOf_fuzz(5e18, 5e18, 0); } - function test_borrowOnBehalfOf_fuzz(uint256 borrowAmount, uint256 creditDelegationAmount) public { + function test_borrowOnBehalfOf_temporaryCreditDelegationTakesPrecedence() public { + uint256 storedAllowance = 300e18; + _fuzzyDelegateCredit( + alice, + alicePk, + bob, + address(spoke1), + _daiReserveId(spoke1), + storedAllowance, + 0 + ); + test_borrowOnBehalfOf_fuzz(5e18, 5e18, 2); + // this check is also performed in test_borrowOnBehalfOf_fuzz, duplicating in case of future changes + assertEq( + positionManager.creditDelegation(address(spoke1), _daiReserveId(spoke1), alice, bob), + storedAllowance + ); + } + + function test_borrowOnBehalfOf_fuzz( + uint256 borrowAmount, + uint256 creditDelegationAmount, + uint256 approvalType + ) public { uint256 aliceSupplyAmount = 5000e18; uint256 bobSupplyAmount = 1000e18; borrowAmount = bound(borrowAmount, 1, bobSupplyAmount); @@ -718,24 +979,40 @@ contract AllowancePositionManagerTest is SpokeBase { Utils.supplyCollateral(spoke1, _daiReserveId(spoke1), alice, aliceSupplyAmount, alice); Utils.supplyCollateral(spoke1, _daiReserveId(spoke1), bob, bobSupplyAmount, bob); - vm.prank(alice); - positionManager.delegateCredit( + approvalType = _fuzzyDelegateCredit( + alice, + alicePk, + bob, address(spoke1), _daiReserveId(spoke1), - bob, - creditDelegationAmount + creditDelegationAmount, + approvalType + ); + + uint256 allowanceBefore = positionManager.creditDelegation( + address(spoke1), + _daiReserveId(spoke1), + alice, + bob + ); + uint256 temporaryAllowanceBefore = positionManager.temporaryCreditDelegation( + address(spoke1), + _daiReserveId(spoke1), + alice, + bob ); uint256 userBalanceBefore = tokenList.dai.balanceOf(alice); uint256 callerBalanceBefore = tokenList.dai.balanceOf(bob); uint256 hubBalanceBefore = tokenList.dai.balanceOf(address(hub1)); + uint256 drawnShares = hub1.previewDrawByAssets(daiAssetId, borrowAmount); vm.expectEmit(address(spoke1)); emit ISpokeBase.Borrow( _daiReserveId(spoke1), address(positionManager), alice, - hub1.previewRestoreByAssets(daiAssetId, borrowAmount), + drawnShares, borrowAmount ); vm.prank(bob); @@ -752,7 +1029,7 @@ contract AllowancePositionManagerTest is SpokeBase { ); assertEq(returnValues.amount, borrowAmount); - assertEq(returnValues.shares, hub1.previewDrawByAssets(daiAssetId, borrowAmount)); + assertEq(returnValues.shares, drawnShares); assertEq(userDrawnDebt + userPremiumDebt, borrowAmount); assertEq(tokenList.dai.balanceOf(address(hub1)), hubBalanceBefore - borrowAmount); @@ -761,11 +1038,18 @@ contract AllowancePositionManagerTest is SpokeBase { assertEq(tokenList.dai.allowance(address(positionManager), address(hub1)), 0); assertEq( positionManager.creditDelegation(address(spoke1), _daiReserveId(spoke1), alice, bob), - creditDelegationAmount - borrowAmount + (approvalType < 2) ? allowanceBefore - borrowAmount : allowanceBefore + ); + assertEq( + positionManager.temporaryCreditDelegation(address(spoke1), _daiReserveId(spoke1), alice, bob), + (approvalType == 2) ? temporaryAllowanceBefore - borrowAmount : temporaryAllowanceBefore ); } - function test_borrowOnBehalfOf_fuzz_noAllowanceDecrease(uint256 borrowAmount) public { + function test_borrowOnBehalfOf_fuzz_noAllowanceDecrease( + uint256 borrowAmount, + uint256 approvalType + ) public { uint256 aliceSupplyAmount = 5000e18; uint256 bobSupplyAmount = 1000e18; borrowAmount = bound(borrowAmount, 1, bobSupplyAmount); @@ -773,8 +1057,15 @@ contract AllowancePositionManagerTest is SpokeBase { Utils.supplyCollateral(spoke1, _daiReserveId(spoke1), alice, aliceSupplyAmount, alice); Utils.supplyCollateral(spoke1, _daiReserveId(spoke1), bob, bobSupplyAmount, bob); - vm.prank(alice); - positionManager.delegateCredit(address(spoke1), _daiReserveId(spoke1), bob, type(uint256).max); + approvalType = _fuzzyDelegateCredit( + alice, + alicePk, + bob, + address(spoke1), + _daiReserveId(spoke1), + type(uint256).max, + approvalType + ); uint256 userBalanceBefore = tokenList.dai.balanceOf(alice); uint256 callerBalanceBefore = tokenList.dai.balanceOf(bob); @@ -811,29 +1102,79 @@ contract AllowancePositionManagerTest is SpokeBase { assertEq(tokenList.dai.allowance(address(positionManager), address(hub1)), 0); assertEq( positionManager.creditDelegation(address(spoke1), _daiReserveId(spoke1), alice, bob), - type(uint256).max + (approvalType < 2) ? type(uint256).max : 0 + ); + assertEq( + positionManager.temporaryCreditDelegation(address(spoke1), _daiReserveId(spoke1), alice, bob), + (approvalType == 2) ? type(uint256).max : 0 + ); + } + + // temporary credit delegation takes precedence over stored credit delegation, and does not cumulate + function test_borrowOnBehalfOf_revertsWith_InsufficientTemporaryCreditDelegation() public { + uint256 storedAllowance = 300e18; + _fuzzyDelegateCredit( + alice, + alicePk, + bob, + address(spoke1), + _daiReserveId(spoke1), + storedAllowance, + 0 + ); + + uint256 amount = 100e18; + uint256 temporaryAllowance = amount - 1; + _fuzzyDelegateCredit( + alice, + alicePk, + bob, + address(spoke1), + _daiReserveId(spoke1), + temporaryAllowance, + 2 + ); + + vm.expectRevert( + abi.encodeWithSelector( + IAllowancePositionManager.InsufficientTemporaryCreditDelegation.selector, + temporaryAllowance, + amount + ) + ); + vm.prank(bob); + positionManager.borrowOnBehalfOf(address(spoke1), _daiReserveId(spoke1), amount, alice); + + assertEq( + positionManager.creditDelegation(address(spoke1), _daiReserveId(spoke1), alice, bob), + storedAllowance ); } - function test_borrowOnBehalfOf_revertsWith_InsufficientCreditDelegation( - uint256 creditDelegationAmount + function test_borrowOnBehalfOf_fuzz_revertsWith_InsufficientAllowance( + uint256 creditDelegationAmount, + uint256 approvalType ) public { uint256 borrowAmount = 100e18; creditDelegationAmount = bound(creditDelegationAmount, 1, borrowAmount - 1); Utils.supplyCollateral(spoke1, _daiReserveId(spoke1), alice, borrowAmount, alice); Utils.supplyCollateral(spoke1, _daiReserveId(spoke1), bob, borrowAmount, bob); - vm.prank(alice); - positionManager.delegateCredit( + approvalType = _fuzzyDelegateCredit( + alice, + alicePk, + bob, address(spoke1), _daiReserveId(spoke1), - bob, - creditDelegationAmount + creditDelegationAmount, + approvalType ); vm.expectRevert( abi.encodeWithSelector( - IAllowancePositionManager.InsufficientCreditDelegation.selector, + (approvalType == 2) + ? IAllowancePositionManager.InsufficientTemporaryCreditDelegation.selector + : IAllowancePositionManager.InsufficientCreditDelegation.selector, creditDelegationAmount, borrowAmount ) @@ -859,6 +1200,92 @@ contract AllowancePositionManagerTest is SpokeBase { positionManager.borrowOnBehalfOf(address(spoke2), 1, 100e18, alice); } + function test_temporaryAllowancesInParallel() public { + _fuzzyApproveWithdraw(alice, alicePk, bob, address(spoke1), _daiReserveId(spoke1), 1e18, 2); + _fuzzyDelegateCredit(alice, alicePk, bob, address(spoke1), _daiReserveId(spoke1), 2e18, 2); + assertEq( + positionManager.temporaryWithdrawAllowance( + address(spoke1), + _daiReserveId(spoke1), + alice, + bob + ), + 1e18 + ); + assertEq( + positionManager.temporaryCreditDelegation(address(spoke1), _daiReserveId(spoke1), alice, bob), + 2e18 + ); + } + + function _fuzzyApproveWithdraw( + address onBehalfOf, + uint256 onBehalfOfPk, + address spender, + address spoke, + uint256 reserveId, + uint256 amount, + uint256 approvalType + ) internal returns (uint256) { + approvalType = bound(approvalType, 0, 2); + if (approvalType == 0) { + vm.prank(onBehalfOf); + positionManager.approveWithdraw(spoke, reserveId, spender, amount); + } else if (approvalType == 1) { + EIP712Types.WithdrawPermit memory p = _withdrawPermitData( + spender, + onBehalfOf, + type(uint256).max + ); + p.spoke = spoke; + p.reserveId = reserveId; + p.amount = amount; + p.nonce = _burnRandomNoncesAtKey(positionManager, onBehalfOf); + bytes memory signature = _sign(onBehalfOfPk, _getTypedDataHash(positionManager, p)); + + vm.prank(vm.randomAddress()); + positionManager.approveWithdrawWithSig(p, signature); + } else { + vm.prank(onBehalfOf); + positionManager.temporaryApproveWithdraw(spoke, reserveId, spender, amount); + } + return approvalType; + } + + function _fuzzyDelegateCredit( + address onBehalfOf, + uint256 onBehalfOfPk, + address spender, + address spoke, + uint256 reserveId, + uint256 amount, + uint256 approvalType + ) internal returns (uint256) { + approvalType = bound(approvalType, 0, 2); + if (approvalType == 0) { + vm.prank(onBehalfOf); + positionManager.delegateCredit(spoke, reserveId, spender, amount); + } else if (approvalType == 1) { + EIP712Types.CreditDelegation memory p = _creditDelegationData( + spender, + onBehalfOf, + type(uint256).max + ); + p.spoke = spoke; + p.reserveId = reserveId; + p.amount = amount; + p.nonce = _burnRandomNoncesAtKey(positionManager, onBehalfOf); + bytes memory signature = _sign(onBehalfOfPk, _getTypedDataHash(positionManager, p)); + + vm.prank(vm.randomAddress()); + positionManager.delegateCreditWithSig(p, signature); + } else { + vm.prank(onBehalfOf); + positionManager.temporaryDelegateCredit(spoke, reserveId, spender, amount); + } + return approvalType; + } + function _withdrawPermitData( address spender, address onBehalfOf,