Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Instant rewards smart contract #171

Merged
merged 7 commits into from
Aug 26, 2024
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
andresaiello marked this conversation as resolved.
Show resolved Hide resolved
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();
}
Dismissed Show dismissed Hide dismissed
andresaiello marked this conversation as resolved.
Show resolved Hide resolved

// 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);
}
Dismissed Show dismissed Hide dismissed
andresaiello marked this conversation as resolved.
Show resolved Hide resolved

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);
}
andresaiello marked this conversation as resolved.
Dismissed
Show resolved Hide resolved
andresaiello marked this conversation as resolved.
Show resolved Hide resolved

function pause() external onlyOwner {
_pause();
}

function unpause() external onlyOwner {
_unpause();
}

receive() external payable {}
}
166 changes: 166 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,166 @@
import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers";
import { expect } from "chai";
import { BigNumber, utils } from "ethers";
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 () => {
andresaiello marked this conversation as resolved.
Show resolved Hide resolved
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 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));
});
});
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);
};
Loading