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(protocol): allow one-tx claim and delegation for bridged ERC20 tokens #15727

Merged
merged 16 commits into from
Feb 11, 2024
30 changes: 25 additions & 5 deletions packages/protocol/contracts/team/airdrop/ERC20Airdrop.sol
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
pragma solidity 0.8.24;

import "lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol";
import "lib/openzeppelin-contracts/contracts/governance/utils/IVotes.sol";
import "./MerkleClaimable.sol";

/// @title ERC20Airdrop
Expand All @@ -25,8 +26,8 @@ contract ERC20Airdrop is MerkleClaimable {
uint256[48] private __gap;

function init(
uint64 _claimStarts,
uint64 _claimEnds,
uint64 _claimStart,
uint64 _claimEnd,
bytes32 _merkleRoot,
address _token,
address _vault
Expand All @@ -35,14 +36,33 @@ contract ERC20Airdrop is MerkleClaimable {
initializer
{
__Essential_init();
_setConfig(_claimStarts, _claimEnds, _merkleRoot);
__MerkleClaimable_init(_claimStart, _claimEnd, _merkleRoot);

token = _token;
vault = _vault;
}

function _claimWithData(bytes calldata data) internal override {
(address user, uint256 amount) = abi.decode(data, (address, uint256));
function claimAndDelegate(
address user,
uint256 amount,
bytes32[] calldata proof,
bytes calldata delegationData
)
external
nonReentrant
{
// Check if this can be claimed
_verifyClaim(abi.encode(user, amount), proof);

// Transfer the tokens
IERC20(token).transferFrom(vault, user, amount);

// Delegate the voting power to delegatee.
// Note that the signature (v,r,s) may not correspond to the user address,
// but since the data is provided by Taiko backend, it's not an issue even if
// client can change the data to call delegateBySig for another user.
(address delegatee, uint256 nonce, uint256 expiry, uint8 v, bytes32 r, bytes32 s) =
abi.decode(delegationData, (address, uint256, uint256, uint8, bytes32, bytes32));
IVotes(token).delegateBySig(delegatee, nonce, expiry, v, r, s);
}
}
20 changes: 11 additions & 9 deletions packages/protocol/contracts/team/airdrop/ERC20Airdrop2.sol
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ contract ERC20Airdrop2 is MerkleClaimable {
}

function init(
uint64 _claimStarts,
uint64 _claimEnds,
uint64 _claimStart,
uint64 _claimEnd,
bytes32 _merkleRoot,
address _token,
address _vault,
Expand All @@ -58,14 +58,21 @@ contract ERC20Airdrop2 is MerkleClaimable {
initializer
{
__Essential_init();
// Unix timestamp=_claimEnds+1 marks the first timestamp the users are able to withdraw.
_setConfig(_claimStarts, _claimEnds, _merkleRoot);
__MerkleClaimable_init(_claimStart, _claimEnd, _merkleRoot);

token = _token;
vault = _vault;
withdrawalWindow = _withdrawalWindow;
}

function claim(address user, uint256 amount, bytes32[] calldata proof) external nonReentrant {
// Check if this can be claimed
_verifyClaim(abi.encode(user, amount), proof);

// Assign the tokens
claimedAmount[user] += amount;
}

/// @notice External withdraw function
/// @param user User address
function withdraw(address user) external ongoingWithdrawals {
Expand Down Expand Up @@ -102,9 +109,4 @@ contract ERC20Airdrop2 is MerkleClaimable {

withdrawableAmount = timeBasedAllowance - withdrawnAmount[user];
}

function _claimWithData(bytes calldata data) internal override {
(address user, uint256 amount) = abi.decode(data, (address, uint256));
claimedAmount[user] += amount;
}
}
19 changes: 14 additions & 5 deletions packages/protocol/contracts/team/airdrop/ERC721Airdrop.sol
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ contract ERC721Airdrop is MerkleClaimable {
uint256[48] private __gap;

function init(
uint64 _claimStarts,
uint64 _claimEnds,
uint64 _claimStart,
uint64 _claimEnd,
bytes32 _merkleRoot,
address _token,
address _vault
Expand All @@ -34,15 +34,24 @@ contract ERC721Airdrop is MerkleClaimable {
initializer
{
__Essential_init();
_setConfig(_claimStarts, _claimEnds, _merkleRoot);
__MerkleClaimable_init(_claimStart, _claimEnd, _merkleRoot);

token = _token;
vault = _vault;
}

function _claimWithData(bytes calldata data) internal override {
(address user, uint256[] memory tokenIds) = abi.decode(data, (address, uint256[]));
function claim(
address user,
uint256[] calldata tokenIds,
bytes32[] calldata proof
)
external
nonReentrant
{
// Check if this can be claimed
_verifyClaim(abi.encode(user, tokenIds), proof);

// Transfer the tokens
for (uint256 i; i < tokenIds.length; ++i) {
IERC721Upgradeable(token).safeTransferFrom(vault, user, tokenIds[i]);
}
Expand Down
63 changes: 36 additions & 27 deletions packages/protocol/contracts/team/airdrop/MerkleClaimable.sol
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@

pragma solidity 0.8.24;

import { MerkleProofUpgradeable } from
"lib/openzeppelin-contracts-upgradeable/contracts/utils/cryptography/MerkleProofUpgradeable.sol";
import "lib/openzeppelin-contracts/contracts/utils/cryptography/MerkleProof.sol";
import "../../common/EssentialContract.sol";

/// @title MerkleClaimable
Expand All @@ -42,27 +41,6 @@ abstract contract MerkleClaimable is EssentialContract {
_;
}

function claim(
bytes calldata data,
bytes32[] calldata proof
)
external
nonReentrant
ongoingClaim
{
bytes32 hash = keccak256(abi.encode("CLAIM_TAIKO_AIRDROP", data));

if (isClaimed[hash]) revert CLAIMED_ALREADY();

if (!MerkleProofUpgradeable.verify(proof, merkleRoot, hash)) {
revert INVALID_PROOF();
}

isClaimed[hash] = true;
_claimWithData(data);
emit Claimed(hash);
}

/// @notice Set config parameters
/// @param _claimStart Unix timestamp for claim start
/// @param _claimEnd Unix timestamp for claim end
Expand All @@ -78,12 +56,43 @@ abstract contract MerkleClaimable is EssentialContract {
_setConfig(_claimStart, _claimEnd, _merkleRoot);
}

function _setConfig(uint64 _claimStart, uint64 _claimEnd, bytes32 _merkleRoot) internal {
// solhint-disable-next-line func-name-mixedcase
function __MerkleClaimable_init(
uint64 _claimStart,
uint64 _claimEnd,
bytes32 _merkleRoot
)
internal
{
_setConfig(_claimStart, _claimEnd, _merkleRoot);
}

function _verifyClaim(bytes memory data, bytes32[] calldata proof) internal ongoingClaim {
bytes32 hash = keccak256(abi.encode("CLAIM_TAIKO_AIRDROP", data));

if (isClaimed[hash]) revert CLAIMED_ALREADY();
if (!_verifyMerkleProof(proof, merkleRoot, hash)) revert INVALID_PROOF();

isClaimed[hash] = true;
emit Claimed(hash);
}

function _verifyMerkleProof(
bytes32[] calldata _proof,
bytes32 _merkleRoot,
bytes32 _value
)
internal
pure
virtual
returns (bool)
{
return MerkleProof.verify(_proof, _merkleRoot, _value);
}

function _setConfig(uint64 _claimStart, uint64 _claimEnd, bytes32 _merkleRoot) private {
claimStart = _claimStart;
claimEnd = _claimEnd;
merkleRoot = _merkleRoot;
}

/// @dev Must revert in case of errors.
function _claimWithData(bytes calldata data) internal virtual;
}
90 changes: 90 additions & 0 deletions packages/protocol/test/team/airdrop/ERC20Airdrop.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;

import "../../TaikoTest.sol";

contract MockERC20Airdrop is ERC20Airdrop {
function _verifyMerkleProof(
bytes32[] calldata, /*proof*/
bytes32, /*merkleRoot*/
bytes32 /*value*/
)
internal
pure
override
returns (bool)
{
return true;
}
}

contract TestERC20Airdrop is TaikoTest {
address public owner = randAddress();

// Private Key: 0x1dc880d28041a41132437eae90c9e09c3b9e13438c2d0f6207804ceece623395
address public Lily = 0x3447b15c1b0a27D339C812b98881eC64051068b3;

bytes32 public constant merkleRoot = bytes32(uint256(1));
bytes32[] public merkleProof;
uint64 public claimStart;
uint64 public claimEnd;

TaikoToken token;
ERC20Airdrop airdrop;

function setUp() public {
claimStart = uint64(block.timestamp + 10);
claimEnd = uint64(block.timestamp + 10_000);
merkleProof = new bytes32[](3);

token = TaikoToken( deployProxy({
name: "taiko_token",
impl: address(new TaikoToken()),
data: abi.encodeCall(TaikoToken.init, ("Taiko Token", "TKO", owner)) }));


airdrop = ERC20Airdrop(
deployProxy({
name: "MockERC20Airdrop",
impl: address(new MockERC20Airdrop()),
data: abi.encodeCall(
ERC20Airdrop.init, (claimStart, claimEnd, merkleRoot, address(token), owner)
)
})
);

vm.roll(block.number + 1);
}

function test_claimAndDelegate_with_wrong_delegation_data() public {
vm.warp(claimStart);

bytes memory delegation = bytes("");

vm.expectRevert("ERC20: insufficient allowance"); // no allowance
vm.prank(Lily, Lily);
airdrop.claimAndDelegate(Lily, 100, merkleProof, delegation);

vm.prank(owner, owner);
token.approve(address(airdrop), 1_000_000_000e18);

vm.expectRevert(); // cannot decode the delegation data
vm.prank(Lily, Lily);
airdrop.claimAndDelegate(Lily, 100, merkleProof, delegation);

address delegatee = randAddress();
uint256 nonce = 1;
uint256 expiry = block.timestamp + 10_000;
uint8 v;
bytes32 r;
bytes32 s;

delegation = abi.encode(delegatee, nonce, expiry, v, r, s);

vm.expectRevert(); // signature invalid
vm.prank(Lily, Lily);
airdrop.claimAndDelegate(Lily, 100, merkleProof, delegation);

// TODO(daniel): add a new test by initializing the right value for the above 6 variables.
}
}
Loading
Loading