Skip to content

Commit

Permalink
feat: Instant rewards smart contract (#171)
Browse files Browse the repository at this point in the history
* feat: Instant rewards smart contract

* refactor

* custom amount to withdraw

* add expiration

* add test

* add more test

* add tests
  • Loading branch information
andresaiello authored Aug 26, 2024
1 parent 7a8eee9 commit 3980860
Show file tree
Hide file tree
Showing 3 changed files with 373 additions and 0 deletions.
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 packages/zevm-app-contracts/test/instant-rewards/instant-rewards.ts
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 packages/zevm-app-contracts/test/instant-rewards/test.helpers.ts
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);
};

0 comments on commit 3980860

Please sign in to comment.