-
Notifications
You must be signed in to change notification settings - Fork 230
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Instant rewards smart contract (#171)
* feat: Instant rewards smart contract * refactor * custom amount to withdraw * add expiration * add test * add more test * add tests
- Loading branch information
1 parent
7a8eee9
commit 3980860
Showing
3 changed files
with
373 additions
and
0 deletions.
There are no files selected for viewing
107 changes: 107 additions & 0 deletions
107
packages/zevm-app-contracts/contracts/instant-rewards/InstantRewards.sol
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,107 @@ | ||
// SPDX-License-Identifier: MIT | ||
pragma solidity 0.8.7; | ||
|
||
import "@openzeppelin/contracts/security/Pausable.sol"; | ||
import "@openzeppelin/contracts/access/Ownable.sol"; | ||
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; | ||
import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; | ||
|
||
contract InstantRewards is Ownable, Pausable, ReentrancyGuard { | ||
/* An ECDSA signature. */ | ||
struct Signature { | ||
uint8 v; | ||
bytes32 r; | ||
bytes32 s; | ||
} | ||
|
||
struct ClaimData { | ||
address to; | ||
Signature signature; | ||
uint256 sigExpiration; | ||
bytes32 taskId; | ||
uint256 amount; | ||
} | ||
|
||
mapping(address => mapping(bytes32 => bool)) public taskCompletedByUser; | ||
|
||
address public signerAddress; | ||
|
||
event Claimed(address indexed to, bytes32 indexed taskId, uint256 amount); | ||
event Withdrawn(address indexed wallet, uint256 amount); | ||
|
||
error InvalidSigner(); | ||
error SignatureExpired(); | ||
error InvalidAddress(); | ||
error TaskAlreadyClaimed(); | ||
error TransferFailed(); | ||
|
||
constructor(address signerAddress_, address owner) Ownable() { | ||
if (signerAddress_ == address(0)) revert InvalidAddress(); | ||
transferOwnership(owner); | ||
signerAddress = signerAddress_; | ||
} | ||
|
||
function _verify(ClaimData memory claimData) private view { | ||
bytes32 payloadHash = _calculateHash(claimData); | ||
|
||
bytes32 messageHash = ECDSA.toEthSignedMessageHash(payloadHash); | ||
|
||
address messageSigner = ECDSA.recover( | ||
messageHash, | ||
claimData.signature.v, | ||
claimData.signature.r, | ||
claimData.signature.s | ||
); | ||
|
||
if (signerAddress != messageSigner) revert InvalidSigner(); | ||
if (block.timestamp > claimData.sigExpiration) revert SignatureExpired(); | ||
} | ||
|
||
// Function to compute the hash of the data and tasks for a token | ||
function _calculateHash(ClaimData memory claimData) private pure returns (bytes32) { | ||
bytes memory encodedData = abi.encode( | ||
claimData.to, | ||
claimData.sigExpiration, | ||
claimData.taskId, | ||
claimData.amount | ||
); | ||
|
||
return keccak256(encodedData); | ||
} | ||
|
||
function claim(ClaimData memory claimData) external whenNotPaused nonReentrant { | ||
claimData.to = msg.sender; | ||
_verify(claimData); | ||
|
||
if (taskCompletedByUser[claimData.to][claimData.taskId]) revert TaskAlreadyClaimed(); | ||
|
||
taskCompletedByUser[claimData.to][claimData.taskId] = true; | ||
|
||
(bool success, ) = claimData.to.call{value: claimData.amount}(""); | ||
if (!success) revert TransferFailed(); | ||
|
||
emit Claimed(claimData.to, claimData.taskId, claimData.amount); | ||
} | ||
|
||
function setSignerAddress(address signerAddress_) external onlyOwner { | ||
if (signerAddress_ == address(0)) revert InvalidAddress(); | ||
signerAddress = signerAddress_; | ||
} | ||
|
||
function withdraw(address wallet, uint256 amount) external onlyOwner { | ||
if (wallet == address(0)) revert InvalidAddress(); | ||
if (amount > address(this).balance) revert TransferFailed(); | ||
payable(wallet).transfer(amount); | ||
emit Withdrawn(wallet, amount); | ||
} | ||
|
||
function pause() external onlyOwner { | ||
_pause(); | ||
} | ||
|
||
function unpause() external onlyOwner { | ||
_unpause(); | ||
} | ||
|
||
receive() external payable {} | ||
} |
233 changes: 233 additions & 0 deletions
233
packages/zevm-app-contracts/test/instant-rewards/instant-rewards.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,233 @@ | ||
import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; | ||
import { expect } from "chai"; | ||
import { BigNumber, utils } from "ethers"; | ||
import { parseEther } from "ethers/lib/utils"; | ||
import { ethers } from "hardhat"; | ||
|
||
import { InstantRewards } from "../../typechain-types"; | ||
import { ClaimData, getSignature } from "./test.helpers"; | ||
|
||
describe("Instant Rewards Contract test", () => { | ||
let instantRewards: InstantRewards, | ||
owner: SignerWithAddress, | ||
signer: SignerWithAddress, | ||
user: SignerWithAddress, | ||
addrs: SignerWithAddress[]; | ||
|
||
const encodeTaskId = (taskId: string) => utils.keccak256(utils.defaultAbiCoder.encode(["string"], [taskId])); | ||
|
||
const getClaimDataSigned = async ( | ||
signer: SignerWithAddress, | ||
amount: BigNumber, | ||
sigExpiration: number, | ||
taskId: string, | ||
to: string | ||
) => { | ||
const claimData: ClaimData = { | ||
amount, | ||
sigExpiration, | ||
taskId, | ||
to, | ||
}; | ||
|
||
const signature = await getSignature(signer, claimData); | ||
return { | ||
...claimData, | ||
signature, | ||
}; | ||
}; | ||
|
||
beforeEach(async () => { | ||
[owner, signer, user, ...addrs] = await ethers.getSigners(); | ||
const instantRewardsFactory = await ethers.getContractFactory("InstantRewards"); | ||
|
||
instantRewards = await instantRewardsFactory.deploy(signer.address, owner.address); | ||
|
||
await instantRewards.deployed(); | ||
}); | ||
|
||
it("Should claim", async () => { | ||
const currentBlock = await ethers.provider.getBlock("latest"); | ||
const sigExpiration = currentBlock.timestamp + 1000; | ||
const amount = utils.parseEther("1"); | ||
const taskId = encodeTaskId("WALLET/TASK/EPOC"); | ||
const to = owner.address; | ||
|
||
// transfer some funds to the contract | ||
await owner.sendTransaction({ | ||
to: instantRewards.address, | ||
value: amount, | ||
}); | ||
|
||
const claimDataSigned = await getClaimDataSigned(signer, amount, sigExpiration, taskId, to); | ||
|
||
const tx = instantRewards.claim(claimDataSigned); | ||
await expect(tx).to.emit(instantRewards, "Claimed").withArgs(owner.address, taskId, amount); | ||
}); | ||
|
||
it("Should claim if pause and unpause", async () => { | ||
const currentBlock = await ethers.provider.getBlock("latest"); | ||
const sigExpiration = currentBlock.timestamp + 1000; | ||
const amount = utils.parseEther("1"); | ||
const taskId = encodeTaskId("WALLET/TASK/EPOC"); | ||
const to = owner.address; | ||
|
||
await instantRewards.pause(); | ||
await instantRewards.unpause(); | ||
|
||
// transfer some funds to the contract | ||
await owner.sendTransaction({ | ||
to: instantRewards.address, | ||
value: amount, | ||
}); | ||
|
||
const claimDataSigned = await getClaimDataSigned(signer, amount, sigExpiration, taskId, to); | ||
|
||
const tx = instantRewards.claim(claimDataSigned); | ||
await expect(tx).to.emit(instantRewards, "Claimed").withArgs(owner.address, taskId, amount); | ||
}); | ||
|
||
it("Should revert if try to claim on behalf of somebody else", async () => { | ||
const currentBlock = await ethers.provider.getBlock("latest"); | ||
const sigExpiration = currentBlock.timestamp + 1000; | ||
const amount = utils.parseEther("1"); | ||
const taskId = encodeTaskId("WALLET/TASK/EPOC"); | ||
const to = user.address; | ||
|
||
const claimDataSigned = await getClaimDataSigned(signer, amount, sigExpiration, taskId, to); | ||
|
||
const tx = instantRewards.claim(claimDataSigned); | ||
await expect(tx).to.revertedWith("InvalidSigner"); | ||
}); | ||
|
||
it("Should revert if try to claim with an expired signature", async () => { | ||
const currentBlock = await ethers.provider.getBlock("latest"); | ||
const sigExpiration = currentBlock.timestamp - 1000; | ||
const amount = utils.parseEther("1"); | ||
const taskId = encodeTaskId("WALLET/TASK/EPOC"); | ||
const to = owner.address; | ||
|
||
const claimDataSigned = await getClaimDataSigned(signer, amount, sigExpiration, taskId, to); | ||
|
||
const tx = instantRewards.claim(claimDataSigned); | ||
await expect(tx).to.revertedWith("SignatureExpired"); | ||
}); | ||
|
||
it("Should revert if try to claim when contract it's paused", async () => { | ||
const currentBlock = await ethers.provider.getBlock("latest"); | ||
const sigExpiration = currentBlock.timestamp + 1000; | ||
const amount = utils.parseEther("1"); | ||
const taskId = encodeTaskId("WALLET/TASK/EPOC"); | ||
const to = owner.address; | ||
|
||
await instantRewards.pause(); | ||
|
||
const claimDataSigned = await getClaimDataSigned(signer, amount, sigExpiration, taskId, to); | ||
|
||
const tx = instantRewards.claim(claimDataSigned); | ||
await expect(tx).to.revertedWith("Pausable: paused"); | ||
}); | ||
|
||
it("Should revert if try to claim twice with same signature", async () => { | ||
const currentBlock = await ethers.provider.getBlock("latest"); | ||
const sigExpiration = currentBlock.timestamp + 1000; | ||
const amount = utils.parseEther("1"); | ||
const taskId = encodeTaskId("WALLET/TASK/EPOC"); | ||
const to = owner.address; | ||
|
||
// transfer some funds to the contract | ||
await owner.sendTransaction({ | ||
to: instantRewards.address, | ||
value: amount, | ||
}); | ||
|
||
const claimDataSigned = await getClaimDataSigned(signer, amount, sigExpiration, taskId, to); | ||
|
||
instantRewards.claim(claimDataSigned); | ||
|
||
const tx = instantRewards.claim(claimDataSigned); | ||
await expect(tx).to.revertedWith("TaskAlreadyClaimed"); | ||
}); | ||
|
||
it("Should revert if try to claim same task with another signature", async () => { | ||
const currentBlock = await ethers.provider.getBlock("latest"); | ||
const sigExpiration = currentBlock.timestamp + 1000; | ||
const amount = utils.parseEther("1"); | ||
const taskId = encodeTaskId("WALLET/TASK/EPOC"); | ||
const to = owner.address; | ||
|
||
// transfer some funds to the contract | ||
await owner.sendTransaction({ | ||
to: instantRewards.address, | ||
value: amount, | ||
}); | ||
|
||
{ | ||
const claimDataSigned = await getClaimDataSigned(signer, amount, sigExpiration, taskId, to); | ||
instantRewards.claim(claimDataSigned); | ||
} | ||
const claimDataSigned = await getClaimDataSigned(signer, amount.add(parseEther("1")), sigExpiration, taskId, to); | ||
const tx = instantRewards.claim(claimDataSigned); | ||
await expect(tx).to.revertedWith("TaskAlreadyClaimed"); | ||
}); | ||
|
||
it("Should revert if try to claim with an old valid signature if a new one was used", async () => { | ||
const currentBlock = await ethers.provider.getBlock("latest"); | ||
const sigExpiration = currentBlock.timestamp + 1000; | ||
const amount = utils.parseEther("2"); | ||
const taskId = encodeTaskId("WALLET/TASK/EPOC"); | ||
const to = owner.address; | ||
|
||
// transfer some funds to the contract | ||
await owner.sendTransaction({ | ||
to: instantRewards.address, | ||
value: amount, | ||
}); | ||
|
||
const claimDataSigned = await getClaimDataSigned(signer, amount, sigExpiration, taskId, to); | ||
|
||
const newClaimDataSigned = await getClaimDataSigned(signer, amount, sigExpiration + 1000, taskId, to); | ||
|
||
instantRewards.claim(newClaimDataSigned); | ||
|
||
const tx = instantRewards.claim(claimDataSigned); | ||
await expect(tx).to.revertedWith("TaskAlreadyClaimed"); | ||
}); | ||
|
||
it("Should revert if not owner try to pause", async () => { | ||
const tx = instantRewards.connect(user).pause(); | ||
await expect(tx).to.revertedWith("Ownable: caller is not the owner"); | ||
}); | ||
|
||
it("Should transfer ownership", async () => { | ||
{ | ||
const ownerAddr = await instantRewards.owner(); | ||
expect(ownerAddr).to.be.eq(owner.address); | ||
} | ||
await instantRewards.transferOwnership(user.address); | ||
{ | ||
const ownerAddr = await instantRewards.owner(); | ||
expect(ownerAddr).to.be.eq(user.address); | ||
} | ||
}); | ||
|
||
it("Should withdraw by owner", async () => { | ||
const amount = utils.parseEther("2"); | ||
const amountToWithdraw = utils.parseEther("1"); | ||
// transfer some funds to the contract | ||
await owner.sendTransaction({ | ||
to: instantRewards.address, | ||
value: amount, | ||
}); | ||
|
||
const userBalanceBefore = await ethers.provider.getBalance(user.address); | ||
|
||
const tx = instantRewards.withdraw(user.address, amountToWithdraw); | ||
await expect(tx).to.emit(instantRewards, "Withdrawn").withArgs(user.address, amountToWithdraw); | ||
|
||
const balanceOfContract = await ethers.provider.getBalance(instantRewards.address); | ||
expect(balanceOfContract).to.be.eq(amount.sub(amountToWithdraw)); | ||
const balanceOfUser = await ethers.provider.getBalance(user.address); | ||
expect(balanceOfUser).to.be.eq(userBalanceBefore.add(amountToWithdraw)); | ||
}); | ||
}); |
33 changes: 33 additions & 0 deletions
33
packages/zevm-app-contracts/test/instant-rewards/test.helpers.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; | ||
import { BigNumber } from "ethers"; | ||
import { ethers } from "hardhat"; | ||
|
||
export interface Signature { | ||
r: string; | ||
s: string; | ||
v: number; | ||
} | ||
|
||
export interface ClaimData { | ||
amount: BigNumber; | ||
sigExpiration: number; | ||
taskId: string; | ||
to: string; | ||
} | ||
|
||
export interface ClaimDataSigned extends ClaimData { | ||
signature: Signature; | ||
} | ||
|
||
export const getSignature = async (signer: SignerWithAddress, claimData: ClaimData) => { | ||
let payload = ethers.utils.defaultAbiCoder.encode( | ||
["address", "uint256", "bytes32", "uint256"], | ||
[claimData.to, claimData.sigExpiration, claimData.taskId, claimData.amount] | ||
); | ||
|
||
const payloadHash = ethers.utils.keccak256(payload); | ||
|
||
// This adds the message prefix | ||
const signature = await signer.signMessage(ethers.utils.arrayify(payloadHash)); | ||
return ethers.utils.splitSignature(signature); | ||
}; |