diff --git a/README.md b/README.md index 4c460bc..fe1276a 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,9 @@ await dispenserProvider.createNewPool([ownerAddress, tokenAddress], params, crea ## Dispense Lock -`dispenseLock` function is responsible for dispensing tokens from a pool to the specified owner based on predefined rules. It ensures that the caller is authorized, the request is valid, and that the signature provided matches the expected one. This function handles simple NFTs and emits an event when tokens are dispensed. +`dispenseLock` function is responsible for dispensing tokens from a pool to the specified receiver based on predefined rules. It ensures that the caller is authorized, the request is valid, and that the signature provided matches the expected one. This function handles simple NFTs and emits an event when tokens are dispensed. + +To call this function, caller must have the pool owner's signature, be the recipient or an approved representative of the recipient, or the pool owner can call it on behalf of a specific user. Upon successful execution, the recipient will receive locked tokens from simple providers. ```solidity /// @notice Dispenses tokens from a locked pool based on provided data and signature. diff --git a/contracts/DispenserModifiers.sol b/contracts/DispenserModifiers.sol index 56ae73d..389838a 100644 --- a/contracts/DispenserModifiers.sol +++ b/contracts/DispenserModifiers.sol @@ -6,16 +6,15 @@ import "./DispenserInternal.sol"; /// @title DispenserModifiers /// @dev Contract for handling the revert logic during token dispensing operations in the Dispenser. abstract contract DispenserModifiers is DispenserInternal { - /// @notice Ensures the caller is either the owner or an approved address for the specified pool. - /// @dev Reverts with a `CallerNotApproved` error if the caller is not the owner or approved. + /// @notice Ensures that the caller is the receiver, owner, or approved by the receiver. + /// @dev Reverts with a `CallerNotApproved` error if the caller is not receiver, owner or approved. /// @param poolId The ID of the pool to verify the caller’s approval for. /// @param receiver The address of the receiver of the tokens. - /// @dev Reverts if the caller is neither the owner nor approved by the owner. - modifier isCallerApproved(uint256 poolId, address receiver) { + modifier isAuthorized(uint256 poolId, address receiver) { if ( - !(receiver == msg.sender || - lockDealNFT.ownerOf(poolId) == msg.sender || - lockDealNFT.isApprovedForAll(receiver, msg.sender)) + !( _isReceiver(receiver) || + _isPoolOwner(poolId) || + _isApprovedByReceiver(receiver)) ) { revert CallerNotApproved(msg.sender, receiver, poolId); } @@ -74,4 +73,24 @@ abstract contract DispenserModifiers is DispenserInternal { } _; } + + /// @notice Ensures that the caller is the receiver. + /// @param receiver The address of the receiver to check. + function _isReceiver(address receiver) private view returns (bool) { + return receiver == msg.sender; + } + + /// @notice Ensures that the caller is the owner of the dispenser pool. + /// @param poolId The pool Id to check. + function _isPoolOwner(uint256 poolId) private view returns (bool) { + return lockDealNFT.ownerOf(poolId) == msg.sender; + } + + /// @notice Ensures that the caller is approved by the receiver. + /// @param receiver The address of the receiver to check. + function _isApprovedByReceiver( + address receiver + ) private view returns (bool) { + return lockDealNFT.isApprovedForAll(receiver, msg.sender); + } } diff --git a/contracts/DispenserProvider.sol b/contracts/DispenserProvider.sol index abcc1dc..67f5060 100644 --- a/contracts/DispenserProvider.sol +++ b/contracts/DispenserProvider.sol @@ -35,7 +35,7 @@ contract DispenserProvider is DispenserModifiers { external firewallProtected validProviderId(poolId) - isCallerApproved(poolId, receiver) + isAuthorized(poolId, receiver) isValidTime(validUntil) isUnclaimed(poolId, receiver) isValidSignature(poolId, validUntil, receiver, data, signature) diff --git a/test/DispenserProvider.ts b/test/DispenserProvider.ts index 0aeed0e..651f585 100644 --- a/test/DispenserProvider.ts +++ b/test/DispenserProvider.ts @@ -13,8 +13,8 @@ import { ethers } from "hardhat" import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers" describe("Dispenser Provider tests", function () { - let owner: SignerWithAddress - let user: SignerWithAddress + let caller: SignerWithAddress + let receiver: SignerWithAddress let signer: SignerWithAddress let dispenserProvider: DispenserProvider let token: ERC20Token @@ -34,7 +34,7 @@ describe("Dispenser Provider tests", function () { const ONE_DAY = 86400 before(async () => { - [owner, user, signer] = await ethers.getSigners() + [caller, receiver, signer] = await ethers.getSigners() const VaultManagerFactory = await ethers.getContractFactory("MockVaultManager") vaultManager = (await VaultManagerFactory.deploy()) as VaultManager const LockDealNFTFactory = await ethers.getContractFactory("LockDealNFT") @@ -60,7 +60,7 @@ describe("Dispenser Provider tests", function () { addresses = [await signer.getAddress(), await token.getAddress()] poolId = await lockDealNFT.totalSupply() await token.approve(await vaultManager.getAddress(), amount) - await dispenserProvider.connect(owner).createNewPool(addresses, params, creationSignature) + await dispenserProvider.createNewPool(addresses, params, creationSignature) validTime = (await time.latest()) + ONE_DAY userData = { simpleProvider: await lockProvider.getAddress(), params: [amount / 2n, validTime] } usersData = [userData] @@ -75,114 +75,118 @@ describe("Dispenser Provider tests", function () { }) it("should deacrease leftAmount after lock", async () => { - const signatureData = [poolId, validTime, await user.getAddress(), userData] + const signatureData = [poolId, validTime, await receiver.getAddress(), userData] const signature = await createSignature(signer, signatureData) - await dispenserProvider.connect(user).dispenseLock(poolId, validTime, await user.getAddress(), usersData, signature) + await dispenserProvider + .connect(receiver) + .dispenseLock(poolId, validTime, await receiver.getAddress(), usersData, signature) expect(await dispenserProvider.poolIdToAmount(poolId)).to.equal(amount / 2n) }) it("should withdraw if available and disper approved", async () => { - await lockDealNFT.connect(user).setApprovalForAll(await dispenserProvider.getAddress(), true) + await lockDealNFT.connect(receiver).setApprovalForAll(await dispenserProvider.getAddress(), true) userData = { simpleProvider: await dealProvider.getAddress(), params: [amount] } usersData = [userData] - const signatureData = [poolId, validTime, await user.getAddress(), userData] + const signatureData = [poolId, validTime, await receiver.getAddress(), userData] const signature = await createSignature(signer, signatureData) - const beforeBalance = await token.balanceOf(await user.getAddress()) - await dispenserProvider.connect(user).dispenseLock(poolId, validTime, await user.getAddress(), usersData, signature) + const beforeBalance = await token.balanceOf(await receiver.getAddress()) + await dispenserProvider + .connect(receiver) + .dispenseLock(poolId, validTime, await receiver.getAddress(), usersData, signature) // check if user has tokens after the withdraw - expect(await token.balanceOf(await user.getAddress())).to.equal(beforeBalance + amount) - await lockDealNFT.connect(user).setApprovalForAll(await dispenserProvider.getAddress(), false) + expect(await token.balanceOf(await receiver.getAddress())).to.equal(beforeBalance + amount) + await lockDealNFT.connect(receiver).setApprovalForAll(await dispenserProvider.getAddress(), false) }) it("should not withdraw if dispenser not approved", async () => { userData = { simpleProvider: await dealProvider.getAddress(), params: [amount] } usersData = [userData] - const signatureData = [poolId, validTime, await user.getAddress(), userData] + const signatureData = [poolId, validTime, await receiver.getAddress(), userData] const signature = await createSignature(signer, signatureData) - const beforeBalance = await token.balanceOf(await user.getAddress()) - await dispenserProvider.connect(user).dispenseLock(poolId, validTime, await user.getAddress(), usersData, signature) + const beforeBalance = await token.balanceOf(await receiver.getAddress()) + await dispenserProvider + .connect(receiver) + .dispenseLock(poolId, validTime, await receiver.getAddress(), usersData, signature) // check if user doesn't have tokens after the withdraw - expect(await token.balanceOf(await user.getAddress())).to.equal(beforeBalance) + expect(await token.balanceOf(await receiver.getAddress())).to.equal(beforeBalance) }) - it("should create lock if approved for all", async () => { - await lockDealNFT.connect(user).setApprovalForAll(await owner.getAddress(), true) - const signatureData = [poolId, validTime, await user.getAddress(), userData] + it("should create a lock if the caller is approved by the receiver.", async () => { + await lockDealNFT.connect(receiver).setApprovalForAll(await caller.getAddress(), true) + const signatureData = [poolId, validTime, await receiver.getAddress(), userData] const signature = await createSignature(signer, signatureData) await expect( - dispenserProvider - .connect(owner) - .dispenseLock(poolId, validTime, await user.getAddress(), usersData, signature) + dispenserProvider.dispenseLock(poolId, validTime, await receiver.getAddress(), usersData, signature) ).to.not.reverted - await lockDealNFT.connect(user).setApprovalForAll(await owner.getAddress(), false) + await lockDealNFT.connect(receiver).setApprovalForAll(await caller.getAddress(), false) }) - + it("should revert double creation", async () => { - const signatureData = [poolId, validTime, await user.getAddress(), userData] + const signatureData = [poolId, validTime, await receiver.getAddress(), userData] const signature = await createSignature(signer, signatureData) await dispenserProvider - .connect(user) - .dispenseLock(poolId, validTime, await user.getAddress(), usersData, signature) + .connect(receiver) + .dispenseLock(poolId, validTime, await receiver.getAddress(), usersData, signature) await expect( dispenserProvider - .connect(user) - .dispenseLock(poolId, validTime, await user.getAddress(), usersData, signature) + .connect(receiver) + .dispenseLock(poolId, validTime, await receiver.getAddress(), usersData, signature) ).to.be.revertedWithCustomError(dispenserProvider, "TokensAlreadyTaken") }) it("should revert invalid signer address", async () => { addresses = [ethers.ZeroAddress, await token.getAddress()] - await expect( - dispenserProvider.connect(owner).createNewPool(addresses, params, creationSignature) - ).to.be.revertedWith("Zero Address is not allowed") + await expect(dispenserProvider.createNewPool(addresses, params, creationSignature)).to.be.revertedWith( + "Zero Address is not allowed" + ) }) - it("should revert if sender is invalid", async () => { - const signatureData = [poolId, validTime, await user.getAddress(), userData] + it("should revert authorization if the caller is neither the owner, the receiver, nor approved by the receiver", async () => { + const signatureData = [poolId, validTime, await receiver.getAddress(), userData] const signature = await createSignature(signer, signatureData) await expect( dispenserProvider - .connect(owner) - .dispenseLock(poolId, validTime, await user.getAddress(), usersData, signature) + .connect(caller) + .dispenseLock(poolId, validTime, await receiver.getAddress(), usersData, signature) ).to.be.revertedWithCustomError(dispenserProvider, "CallerNotApproved") }) it("should revert zero token address", async () => { addresses = [await signer.getAddress(), ethers.ZeroAddress] - await expect( - dispenserProvider.connect(owner).createNewPool(addresses, params, creationSignature) - ).to.be.revertedWith("Zero Address is not allowed") + await expect(dispenserProvider.createNewPool(addresses, params, creationSignature)).to.be.revertedWith( + "Zero Address is not allowed" + ) }) it("should revert invalid amount", async () => { params = [0n] - await expect( - dispenserProvider.connect(owner).createNewPool(addresses, params, creationSignature) - ).to.be.revertedWith("amount must be greater than 0") + await expect(dispenserProvider.createNewPool(addresses, params, creationSignature)).to.be.revertedWith( + "amount must be greater than 0" + ) }) it("should emit PoolCreated event", async () => { - const signatureData = [poolId, validTime, await user.getAddress(), userData] + const signatureData = [poolId, validTime, await receiver.getAddress(), userData] const signature = await createSignature(signer, signatureData) await expect( dispenserProvider - .connect(user) - .dispenseLock(poolId, validTime, await user.getAddress(), usersData, signature) + .connect(receiver) + .dispenseLock(poolId, validTime, await receiver.getAddress(), usersData, signature) ) .to.emit(dispenserProvider, "PoolCreated") .withArgs(poolId + 1n, await lockProvider.getAddress()) }) it("should emit TokensDispensed event", async () => { - const signatureData = [poolId, validTime, await user.getAddress(), userData] + const signatureData = [poolId, validTime, await receiver.getAddress(), userData] const signature = await createSignature(signer, signatureData) await expect( dispenserProvider - .connect(user) - .dispenseLock(poolId, validTime, await user.getAddress(), usersData, signature) + .connect(receiver) + .dispenseLock(poolId, validTime, await receiver.getAddress(), usersData, signature) ) .to.emit(dispenserProvider, "TokensDispensed") - .withArgs(poolId, await user.getAddress(), amount / 2n, amount / 2n) + .withArgs(poolId, await receiver.getAddress(), amount / 2n, amount / 2n) }) it("should support IERC165 interface", async () => { @@ -196,24 +200,35 @@ describe("Dispenser Provider tests", function () { it("should revert if params amount greater than leftAmount", async () => { userData = { simpleProvider: await lockProvider.getAddress(), params: [amount, validTime] } const usersData = [userData, userData] - const signatureData = [poolId, validTime, await user.getAddress(), userData, userData] + const signatureData = [poolId, validTime, await receiver.getAddress(), userData, userData] const signature = await createSignature(signer, signatureData) await expect( dispenserProvider - .connect(user) - .dispenseLock(poolId, validTime, await user.getAddress(), usersData, signature) + .connect(receiver) + .dispenseLock(poolId, validTime, await receiver.getAddress(), usersData, signature) ).to.be.revertedWithCustomError(dispenserProvider, "NotEnoughTokensInPool") }) it("should revert zero params amount", async () => { const invalidUserData = { simpleProvider: await lockProvider.getAddress(), params: [0, validTime] } const usersData = [userData, invalidUserData] - const signatureData = [poolId, validTime, await user.getAddress(), userData, invalidUserData] + const signatureData = [poolId, validTime, await receiver.getAddress(), userData, invalidUserData] const signature = await createSignature(signer, signatureData) await expect( dispenserProvider - .connect(user) - .dispenseLock(poolId, validTime, await user.getAddress(), usersData, signature) + .connect(receiver) + .dispenseLock(poolId, validTime, await receiver.getAddress(), usersData, signature) ).to.be.revertedWithCustomError(dispenserProvider, "AmountMustBeGreaterThanZero") }) + + it("should allow the pool owner to call dispense for the receiver", async () => { + const signatureData = [poolId, validTime, await receiver.getAddress(), userData] + const signature = await createSignature(signer, signatureData) + const balanceBefore = await lockDealNFT["balanceOf(address)"](await receiver.getAddress()) + await dispenserProvider + .connect(signer) + .dispenseLock(poolId, validTime, await receiver.getAddress(), usersData, signature) + const balanceAfter = await lockDealNFT["balanceOf(address)"](await receiver.getAddress()) + expect(balanceAfter).to.equal(balanceBefore + 1n) + }) })