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: migrate Vouchers #15

Merged
merged 3 commits into from
Sep 25, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
28 changes: 28 additions & 0 deletions src/interfaces/IVoucher.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface IVoucher {
error InvalidIssuer();
error InvalidSignature();
error InvalidChainId();
error InvalidRouter();
error InvalidExecutor();
error VoucherExpired();
error VoucherAlreadyUsed();
error InvalidVouchersLength();

event Used(IVoucher.Voucher voucher);

struct Voucher {
uint32 chainId;
address router;
address executor;
address beneficiary;
uint64 expireAt;
uint128 nonce;
bytes data;
bytes signature;
}

function use(IVoucher.Voucher[] calldata vouchers) external;
}
12 changes: 12 additions & 0 deletions src/interfaces/IVoucherExecutor.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface IVoucherExecutor {
error CallerIsNotRouter();

error InvalidPayload();

event Executed(address indexed beneficiary, bytes data);

function execute(address beneficiary, bytes calldata data) external;
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import {IAuthorize} from "../../src/interfaces/IAuthorize.sol";
import "../../interfaces/IAuthorize.sol";

contract TrueAuthorize is IAuthorize {
function authorize(address, address, uint256) external pure override returns (bool) {
Expand Down
23 changes: 23 additions & 0 deletions src/voucher/VoucherExecutorBase.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

import {IVoucherExecutor} from "../interfaces/IVoucherExecutor.sol";

abstract contract VoucherExecutorBase is IVoucherExecutor, ReentrancyGuard {
address public router;

constructor(address router_) {
router = router_;
}

function execute(address beneficiary, bytes calldata data) external nonReentrant {
if (msg.sender != router) revert IVoucherExecutor.CallerIsNotRouter();
_execute(beneficiary, data);
emit Executed(beneficiary, data);
}

/// @dev Override this function to implement voucher execution logic
function _execute(address, bytes calldata) internal virtual;
}
22 changes: 22 additions & 0 deletions src/voucher/VoucherLib.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import {IVoucher} from "../interfaces/IVoucher.sol";

library VoucherLib {
function pack(IVoucher.Voucher memory voucher) internal pure returns (bytes memory) {
return abi.encode(
voucher.chainId,
voucher.router,
voucher.executor,
voucher.beneficiary,
voucher.expireAt,
voucher.nonce,
voucher.data
);
}

function hash(IVoucher.Voucher memory voucher) internal pure returns (bytes32) {
return keccak256(pack(voucher));
}
}
76 changes: 76 additions & 0 deletions src/voucher/VoucherRouter.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";

import {VoucherLib} from "./VoucherLib.sol";
import {IVoucher} from "../interfaces/IVoucher.sol";
import {IVoucherExecutor} from "../interfaces/IVoucherExecutor.sol";

contract VoucherRouter is IVoucher, Ownable2Step, ReentrancyGuard {
using MessageHashUtils for bytes32;
using ECDSA for bytes32;
using VoucherLib for IVoucher.Voucher;

address public defaultIssuer;
mapping(address executor => address issuer) public executorIssuers;
mapping(uint128 uid => bool isUsed) public usedVouchers;

constructor(address owner, address defaultIssuer_) Ownable(owner) {
if (defaultIssuer_ == address(0)) revert IVoucher.InvalidIssuer();
defaultIssuer = defaultIssuer_;
}

function setDefaultIssuer(address issuer) external onlyOwner {
if (issuer == address(0)) revert IVoucher.InvalidIssuer();
defaultIssuer = issuer;
}

function setExecutorIssuer(address executor, address issuer) external onlyOwner {
executorIssuers[executor] = issuer;
}

function use(IVoucher.Voucher[] calldata vouchers) external nonReentrant {
if (vouchers.length == 0) {
revert InvalidVouchersLength();
}

for (uint256 i = 0; i < vouchers.length; i++) {
_validateSignature(vouchers[i]);
_validateVoucher(vouchers[i]);
_routeVoucher(vouchers[i]);
emit IVoucher.Used(vouchers[i]);
}
}

function _validateSignature(IVoucher.Voucher calldata voucher) internal view {
address issuer = executorIssuers[voucher.executor];
if (issuer == address(1)) return;

if (issuer == address(0)) {
issuer = defaultIssuer;
}

address recovered = voucher.hash().toEthSignedMessageHash().recover(voucher.signature);
if (recovered != issuer) revert IVoucher.InvalidSignature();
}

function _validateVoucher(IVoucher.Voucher calldata voucher) internal view {
if (voucher.chainId != block.chainid) revert IVoucher.InvalidChainId();
if (voucher.router != address(this)) revert IVoucher.InvalidRouter();
if (voucher.executor == address(0)) revert IVoucher.InvalidExecutor();
if (block.timestamp > voucher.expireAt) {
revert IVoucher.VoucherExpired();
}
if (usedVouchers[voucher.nonce]) revert IVoucher.VoucherAlreadyUsed();
}

function _routeVoucher(IVoucher.Voucher calldata voucher) internal {
usedVouchers[voucher.nonce] = true;
IVoucherExecutor(voucher.executor).execute(voucher.beneficiary, voucher.data);
}
}
67 changes: 67 additions & 0 deletions src/voucher/executors/ClaimExecutor.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import {VoucherExecutorBase} from "../VoucherExecutorBase.sol";

contract ClaimExecutor is VoucherExecutorBase {
struct UserData {
uint64 latestClaimTimestamp;
uint128 streakCount;
}

struct DailyClaimPayload {
uint64 points;
}

uint256 public constant COOLDOWN_DAYS = 1;

mapping(address user => UserData userData) public usersData;

/**
* @notice Event emitted when a user claims their daily reward,
* submitting their reward points for yesterday.
* @param user Address of the user who claimed the reward
* @param points Reward points submitted by the user
* @param streakCount Current streak count of the user
*/
event Claimed(address indexed user, uint256 points, uint128 streakCount);

/**
* @notice Error thrown when claiming the reward that is on cooldown
* @param cooldownDeadline Timestamp of the end of the cooldown period
*/
error RewardOnCooldown(uint256 cooldownDeadline);

constructor(address router) VoucherExecutorBase(router) {}

/**
* @notice Internal executor function that handles both streak check-in and daily reward claim
* @dev This function merges the logic of both streak check-ins and daily claim rewards
* @param beneficiary The address of the user performing the check-in and claim
* @param data The calldata for daily reward claim (contains `points`)
*/
function _execute(address beneficiary, bytes calldata data) internal override {
UserData storage userData = usersData[beneficiary];

uint16 UTCDaysSinceLastUserClaim = uint16(block.timestamp / 1 days - userData.latestClaimTimestamp / 1 days);

if (UTCDaysSinceLastUserClaim < COOLDOWN_DAYS) {
revert RewardOnCooldown((userData.latestClaimTimestamp / 1 days + 1) * 1 days);
}

DailyClaimPayload memory dcp = abi.decode(data, (DailyClaimPayload));
if (dcp.points == 0) {
revert InvalidPayload();
}

if (block.timestamp / 1 days - userData.latestClaimTimestamp / 1 days == 1) {
userData.streakCount++;
} else {
userData.streakCount = 1;
}

userData.latestClaimTimestamp = uint64(block.timestamp);

emit Claimed(beneficiary, dcp.points, userData.streakCount);
}
}
12 changes: 12 additions & 0 deletions src/voucher/executors/DummyExecutor.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import {VoucherExecutorBase} from "../VoucherExecutorBase.sol";

contract DummyExecutor is VoucherExecutorBase {
constructor(address router) VoucherExecutorBase(router) {}

function _execute(address, bytes calldata) internal override {
// do nothing
}
}
87 changes: 87 additions & 0 deletions src/voucher/executors/LiteStreakExecutor.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol";

import {VoucherExecutorBase} from "../VoucherExecutorBase.sol";

contract LiteStreakExecutor is VoucherExecutorBase, Ownable2Step {
struct CheckinData {
uint128 latestCheckinDay;
uint128 streakCount;
}

mapping(address user => CheckinData userData) internal _userCheckins;

uint128 public streakSize = 7;
uint256 public regularPoints = 10;
uint256 public streakPoints = 100;

/**
* @notice Emitted when a user checks in.
* @param user The user that checked in.
* @param streak The user's current streak.
*/
event Checkin(address indexed user, uint128 streak, uint256 points);

/**
* @notice Error thrown when a user tries to check in while on cooldown.
* @param availableAt The timestamp when the user can check in again.
*/
error CheckinOnCooldown(uint256 availableAt);

constructor(address owner, address router) Ownable(owner) VoucherExecutorBase(router) {}

/**
* @notice Get the check-in data for a user.
* @param userAddress The address of the user.
* @return checkinData The check-in data for the user.
*/
function getCheckinData(address userAddress) public view returns (CheckinData memory) {
return _userCheckins[userAddress];
}

/**
* @notice Set the streak size, non-streak points, and streak points.
* @dev Callable only by the owner.
* @param streakSize_ The new streak size.
* @param regularPoints_ The new points for non-streak days.
* @param streakPoints_ The new points for streak days.
*/
function setConfig(uint128 streakSize_, uint256 regularPoints_, uint256 streakPoints_) public onlyOwner {
streakSize = streakSize_;
regularPoints = regularPoints_;
streakPoints = streakPoints_;
}

function _execute(address beneficiary, bytes calldata) internal override {
CheckinData storage userCI = _userCheckins[beneficiary];

if (block.timestamp / 1 days <= userCI.latestCheckinDay) {
revert CheckinOnCooldown((block.timestamp / 1 days + 1) * 1 days);
}

if (block.timestamp / 1 days - userCI.latestCheckinDay == 1) {
userCI.streakCount++;
} else {
/// @dev Reset streak if more than 2 days have passed
userCI.streakCount = 1;
}

userCI.latestCheckinDay = uint128(block.timestamp / 1 days);

uint256 points = regularPoints;
uint128 streakCount = userCI.streakCount;

if (userCI.streakCount >= streakSize) {
points = streakPoints;
/// @dev Reset streak if reached
userCI.streakCount = 0;
streakCount = streakSize;
}

// Emit the Checkin event with the generated random number
emit Checkin(beneficiary, streakCount, points);
}
}
16 changes: 16 additions & 0 deletions src/voucher/executors/test/TestClaimExecutor.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import {ClaimExecutor} from "../ClaimExecutor.sol";

contract TestClaimExecutor is ClaimExecutor {
constructor(address router) ClaimExecutor(router) {}

function workaround_setUsersData(address user, uint64 latestClaimTimestamp, uint128 streakCount) public {
usersData[user] = UserData(latestClaimTimestamp, streakCount);
}

function exposed_execute(address beneficiary, bytes calldata data) public {
_execute(beneficiary, data);
}
}
20 changes: 20 additions & 0 deletions src/voucher/executors/test/TestLiteStreakExecutor.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import {LiteStreakExecutor} from "../LiteStreakExecutor.sol";

contract TestLiteStreakExecutor is LiteStreakExecutor {
constructor(address owner, address router) LiteStreakExecutor(owner, router) {}

function workaround_setStreakSize(uint128 size) public {
streakSize = size;
}

function workaround_setCheckinData(address user, uint128 latestCheckinDay, uint128 streakCount) public {
_userCheckins[user] = CheckinData(latestCheckinDay, streakCount);
}

function exposed_execute(address beneficiary, bytes calldata data) public {
_execute(beneficiary, data);
}
}
14 changes: 14 additions & 0 deletions src/voucher/test/MockedVoucherExecutor.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import {VoucherExecutorBase} from "../VoucherExecutorBase.sol";

contract MockedVoucherExecutor is VoucherExecutorBase {
uint256 public nonce;

constructor(address router_) VoucherExecutorBase(router_) {}

function _execute(address, bytes calldata) internal override {
nonce++;
}
}
Loading