Skip to content
Open
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
207 changes: 80 additions & 127 deletions src/CommitProtocol.sol
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,18 @@ import {ERC1155PausableUpgradeable} from
import {ReentrancyGuardUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol";
import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import {AccessControlUpgradeable} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol";
import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol";
import {MerkleProof} from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
import {TokenUtils} from "./libraries/TokenUtils.sol";
import {IVerifier} from "./interfaces/IVerifier.sol";

/// @title CommitProtocol
/// @notice Enables users to create and participate in commitment-based challenges

contract CommitProtocol is
UUPSUpgradeable,
ReentrancyGuardUpgradeable,
OwnableUpgradeable,
AccessControlUpgradeable,
ERC1155Upgradeable,
ERC1155PausableUpgradeable,
ERC1155SupplyUpgradeable
Expand All @@ -29,24 +30,25 @@ contract CommitProtocol is

/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers(); // This only affects the implementation contract
_disableInitializers(); // This only affects the implementation contract
}

function initialize(address initialOwner) public initializer {
__Ownable_init(initialOwner);
__AccessControl_init();
__UUPSUpgradeable_init();
__ReentrancyGuard_init();
__ERC1155Pausable_init();
__ERC1155_init("");
name = "COMMIT";
symbol = "COMMIT";
_grantRole(DEFAULT_ADMIN_ROLE, initialOwner);
}

event TokenApproved(address indexed token, bool isApproved);
event Created(uint256 indexed commitId, Commit config);
event Funded(uint256 indexed commitId, address indexed funder, address indexed token, uint256 amount);
event Joined(uint256 indexed commitId, address indexed participant);
event Verified(uint256 indexed commitId, address indexed participant, bool isVerified);
event Finalized(uint256 indexed commitId, bytes32 merkleRoot, string distributionURI);
event Claimed(uint256 indexed commitId, address indexed participant, address indexed token, uint256 amount);
event ClaimedFees(address indexed recipient, address indexed token, uint256 amount);
event Withdraw(uint256 indexed commitId, address indexed recipient, address indexed token, uint256 amount);
Expand Down Expand Up @@ -82,17 +84,17 @@ contract CommitProtocol is
uint256 joinBefore; // Deadline for participants to join
uint256 verifyBefore; // Deadline for verifications
uint256 maxParticipants; // Limit on how many can join (0 = unlimited)
Verifier joinVerifier; // Logic to verify eligibility to join
Verifier fulfillVerifier; // Logic to verify commit completion
address token; // Primary token for stake
uint256 stake; // Amount each participant must stake
uint256 fee; // Creator fee (taken from each participant’s stake)
ClientConfig client; // Optional client fees, e.g. partners or DApps
string rrule; // Recurrence rule as a string (e.g. "FREQ=DAILY;COUNT=10"). This will be parsed by the trusted backend service
Verification verification;
}

struct Verifier {
address target; // Contract to call for verification logic
bytes data; // Arguments or config for the verifier
struct Verification {
string variant; // erc20, strava, etc
bytes data; // Encode data used for verification (eg. length of daily run, amount of erc20 token)
}

struct ClientConfig {
Expand All @@ -113,6 +115,8 @@ contract CommitProtocol is

}

bytes32 public constant TRUSTED_ROLE = keccak256("TRUSTED_ROLE"); // Trusted backend services

// Protocol-wide configuration
ProtocolConfig public config;

Expand All @@ -134,12 +138,18 @@ contract CommitProtocol is
// token => (commitId => (funder => amount))
mapping(address => mapping(uint256 => mapping(address => uint256))) public fundsByAddress;

// commitId => merkle root for reward distribution
mapping(uint256 => bytes32) public merkleRoots;

// commitId => distribution URI containing full merkle tree
mapping(uint256 => string) public distributionURIs;

// commitId => (participant => claimed)
mapping(uint256 => mapping(address => bool)) public hasClaimed;

// token => (recipient => amount) for fees or distributions
mapping(address => mapping(address => uint256)) public claims;

// token => (commitId => per-participant reward)
mapping(address => mapping(uint256 => uint256)) public rewards;

// commitId => number of verified participants
mapping(uint256 => uint256) public verifiedCount;

Expand Down Expand Up @@ -201,7 +211,6 @@ contract CommitProtocol is

/**
* @notice Participant joins a commit, staking tokens and paying the protocol join fee in ETH.
* @dev The joinVerifier can be used to apply custom eligibility checks.
*/
function join(uint256 commitId, bytes calldata data) public payable nonReentrant {
Commit memory commit = getCommit(commitId);
Expand All @@ -225,14 +234,6 @@ contract CommitProtocol is
revert MaxParticipantsReached(commitId);
}

// Optionally verify participant’s eligibility
if (commit.joinVerifier.target != address(0)) {
bool ok = IVerifier(commit.joinVerifier.target).verify(msg.sender, commit.joinVerifier.data, data);
if (!ok) {
revert InvalidParticipantStatus(commitId, msg.sender, "not-eligible-join");
}
}

// Handle ETH (needed because TokenUtils check msg.value == amout)
if (commit.token == address(0)) {
require(msg.value == commit.stake + commit.fee + config.fee.fee, "Incorrect ETH amount sent");
Expand Down Expand Up @@ -327,113 +328,56 @@ contract CommitProtocol is
emit Withdraw(commitId, msg.sender, token, amount);
}

/**
* @notice Anyone can call verify to confirm a participant has completed their commit.
*/
function verify(uint256 commitId, address participant, bytes calldata data)
public
payable
nonReentrant
returns (bool)
function finalize(uint256 commitId, bytes32 merkleRoot, string calldata distributionURI)
external
onlyRole(TRUSTED_ROLE)
{
Commit memory c = getCommit(commitId);
Commit memory commit = getCommit(commitId);
if (status[commitId] != CommitStatus.created) {
revert InvalidCommitStatus(commitId, "not-created");
}
if (status[commitId] == CommitStatus.cancelled) {
revert InvalidCommitStatus(commitId, "cancelled");
}
if (block.timestamp >= c.verifyBefore) {
revert CommitClosed(commitId, "verify");
}
if (participants[commitId][participant] != ParticipantStatus.joined) {
revert InvalidParticipantStatus(commitId, participant, "not-joined");
}
// Update state before calling verifier (could be an untrusted verifier contract)
participants[commitId][participant] = ParticipantStatus.verified;
verifiedCount[commitId]++;
// Use fulfillVerifier to check if participant truly completed the commit
bool ok = IVerifier(c.fulfillVerifier.target).verify(participant, c.fulfillVerifier.data, data);

// If verification fails, revert the state changes
if (!ok) {
participants[commitId][participant] = ParticipantStatus.joined;
verifiedCount[commitId]--;
if (block.timestamp < commit.verifyBefore) {
revert InvalidCommitStatus(commitId, "verify-period-not-ended");
}

emit Verified(commitId, participant, ok);
return ok;
merkleRoots[commitId] = merkleRoot;
distributionURIs[commitId] = distributionURI;

emit Finalized(commitId, merkleRoot, distributionURI);
}

/**
* @notice Verified participants can claim their reward. The reward is distributed
* proportionally among verified users (minus protocol/client fees).
*/
function claim(uint256 commitId, address participant) public payable nonReentrant {
if (status[commitId] == CommitStatus.cancelled) {
revert InvalidCommitStatus(commitId, "cancelled");
function claim(uint256 commitId, address participant, uint256 amount, bytes32[] calldata proof)
external
nonReentrant
{
Commit memory commit = getCommit(commitId);
if (status[commitId] != CommitStatus.created) {
revert InvalidCommitStatus(commitId, "not-created");
}
if (hasClaimed[commitId][participant]) {
revert InvalidParticipantStatus(commitId, participant, "already-claimed");
}
if (participants[commitId][participant] != ParticipantStatus.verified) {
revert InvalidParticipantStatus(commitId, participant, "not-verified");
}
participants[commitId][participant] = ParticipantStatus.claimed;

// Distribute for each approved token used by the commit
uint256 length = commitTokens[commitId].length();
for (uint256 i = 0; i < length; i++) {
address token = commitTokens[commitId].at(i);

// Calculate distribution if not done already
if (rewards[token][commitId] == 0) {
distribute(commitId, token);
}

uint256 reward = rewards[token][commitId];
// Transfer reward to participant
if (reward > 0) {
TokenUtils.transfer(token, participant, reward);
emit Claimed(commitId, participant, token, reward);
}
}
}

/**
* @notice Splits the total pool of tokens among verified participants, allocating fee shares
* to the protocol and client.
*/
function distribute(uint256 commitId, address token) public {
if (status[commitId] == CommitStatus.cancelled) {
revert InvalidCommitStatus(commitId, "cancelled");
}
Commit memory commit = getCommit(commitId);
if (block.timestamp <= commit.verifyBefore) {
revert CommitClosed(commitId, "verify still open");
}
// Verify the merkle proof
bytes32 leaf = keccak256(abi.encodePacked(participant, amount));
bool isValid = MerkleProof.verify(proof, merkleRoots[commitId], leaf);
require(isValid, "Invalid merkle proof");

uint256 amount = funds[token][commitId];
hasClaimed[commitId][participant] = true;
participants[commitId][participant] = ParticipantStatus.claimed;

// If no participants succeeded, just mark commit as cancelled
// and let participants/funders call `refund()`. Skip fees entirely.
if (verifiedCount[commitId] == 0) {
status[commitId] = CommitStatus.cancelled;
return;
// Transfer the reward amount
if (commit.token == address(0)) {
(bool success,) = payable(participant).call{value: amount}("");
require(success, "ETH transfer failed");
} else {
TokenUtils.transfer(commit.token, participant, amount);
}

// Otherwise, proceed with normal distribution
funds[token][commitId] = 0;
uint256 clientShare = (amount * commit.client.shareBps) / 10000;
uint256 protocolShare = (amount * config.fee.shareBps) / 10000;
uint256 rewardsPool = amount - clientShare - protocolShare;

// Each verified participant’s share
rewards[token][commitId] = rewardsPool / verifiedCount[commitId];

// Any rounding remainder goes to protocol
protocolShare += (rewardsPool % verifiedCount[commitId]);

// Update claims
claims[token][commit.client.recipient] += clientShare;
claims[token][config.fee.recipient] += protocolShare;
emit Claimed(commitId, participant, commit.token, amount);
}

/**
Expand Down Expand Up @@ -479,14 +423,6 @@ contract CommitProtocol is
emit Refunded(commitId, msg.sender, commit.token, amount);
}

function verifyOverride(uint256 commitId, address participant) public onlyOwner {
if (participants[commitId][participant] == ParticipantStatus.verified) {
revert InvalidParticipantStatus(commitId, participant, "already-verified");
}
participants[commitId][participant] = ParticipantStatus.verified;
verifiedCount[commitId]++;
}

/**
* @notice Returns the details of a specific commit by ID.
*/
Expand All @@ -498,7 +434,7 @@ contract CommitProtocol is
/**
* @notice Allows the owner to approve or revoke approval for a token to be used in commits.
*/
function approveToken(address token, bool isApproved) public onlyOwner {
function approveToken(address token, bool isApproved) public onlyRole(DEFAULT_ADMIN_ROLE) {
isApproved ? approvedTokens.add(token) : approvedTokens.remove(token);
emit TokenApproved(token, isApproved);
}
Expand All @@ -522,26 +458,34 @@ contract CommitProtocol is
* for example: https://commit.wtf/api/commit/{id}.json
* this endpoint will dynamically generate the metadata based on token status (verified, claimed, rewards etc)
*/
function setURI(string memory uri) public onlyOwner {
function setURI(string memory uri) public onlyRole(DEFAULT_ADMIN_ROLE) {
_setURI(uri);
}

/**
* @notice Allows the owner to update the protocol-wide config.
*/
function setProtocolConfig(ProtocolConfig calldata _c) public onlyOwner {
function setProtocolConfig(ProtocolConfig calldata _c) public onlyRole(DEFAULT_ADMIN_ROLE) {
config = _c;
}

function pause() public onlyOwner {
/**
* @notice Adds a new trusted verifier that can finalize commits
* @param verifier Address of the verifier to add
*/
function addVerifier(address verifier) external onlyRole(DEFAULT_ADMIN_ROLE) {
_grantRole(TRUSTED_ROLE, verifier);
}

function pause() public onlyRole(DEFAULT_ADMIN_ROLE) {
_pause();
}

function unpause() public onlyOwner {
function unpause() public onlyRole(DEFAULT_ADMIN_ROLE) {
_unpause();
}

function _authorizeUpgrade(address newImplementation) internal override onlyOwner {}
function _authorizeUpgrade(address newImplementation) internal override onlyRole(DEFAULT_ADMIN_ROLE) {}

// The following functions are overrides required by Solidity.

Expand All @@ -555,7 +499,16 @@ contract CommitProtocol is
/**
* @notice Owner-only function to withdraw tokens in emergencies (if needed).
*/
function emergencyWithdraw(address token, uint256 amount) public onlyOwner {
function emergencyWithdraw(address token, uint256 amount) public onlyRole(DEFAULT_ADMIN_ROLE) {
TokenUtils.transfer(token, msg.sender, amount);
}

function supportsInterface(bytes4 interfaceId)
public
view
override(ERC1155Upgradeable, AccessControlUpgradeable)
returns (bool)
{
return super.supportsInterface(interfaceId);
}
}
Loading