diff --git a/contracts/Bucket.sol b/contracts/Bucket.sol new file mode 100644 index 0000000..cc8975b --- /dev/null +++ b/contracts/Bucket.sol @@ -0,0 +1,161 @@ +pragma solidity ^0.6.1; +pragma experimental ABIEncoderV2; + +abstract contract Bucket { + bool initialized; + address payable public owner; + address public tokenAddress; + uint256 public expirationTime; + uint256 public startTime; + + uint256 constant maxTxDelayInBlocks = 10; + bytes32 constant REDEEM_TYPEHASH = keccak256("Redeem(uint256 blockNumber,bytes32 blockHash,address receiver,bytes32 code)"); + bytes32 constant EIP712DOMAIN_TYPEHASH = keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); + bytes32 DOMAIN_SEPARATOR; + + struct Gift { + address recipient; + bytes32 code; + uint256 data; + } + + struct Redeem { + uint256 blockNumber; + bytes32 blockHash; + address receiver; + bytes32 code; + } + + mapping(address => Gift) public gifts; + + modifier onlyOwner() { + require(msg.sender == owner, "owner required"); + _; + } + + constructor(bytes memory _eip712DomainName, address _tokenAddress, uint256 _startTime, uint256 _expirationTime) public { + initialize(_eip712DomainName, _tokenAddress, _startTime, _expirationTime, msg.sender); + } + + function initialize(bytes memory _eip712DomainName, address _tokenAddress, uint256 _startTime, uint256 _expirationTime, address _owner) public { + require(initialized == false, "already initialized"); + + validateExpiryDate(_expirationTime); + + tokenAddress = _tokenAddress; + startTime = _startTime; + expirationTime = _expirationTime; + owner = payable(_owner); + + DOMAIN_SEPARATOR = keccak256(abi.encode( + EIP712DOMAIN_TYPEHASH, + keccak256(_eip712DomainName), + keccak256("1"), + getChainID(), + address(this) + )); + + initialized = true; + } + + function transferRedeemable(uint256 data, Redeem memory redeem) virtual internal; + + function transferRedeemablesToOwner() virtual internal; + + function redeem(Redeem calldata _redeem, bytes calldata _sig) external { + validateRedeem(_redeem, maxTxDelayInBlocks, expirationTime, startTime); + + address recipient = recoverSigner(DOMAIN_SEPARATOR, _redeem, _sig); + + Gift storage gift = gifts[recipient]; + require(gift.recipient == recipient, "not found"); + + validateCode(_redeem, gift.code); + + uint256 data = gift.data; + + gift.recipient = address(0); + gift.code = 0; + gift.data = 0; + + transferRedeemable(data, _redeem); + } + + + function kill() external onlyOwner { + validateExpired(expirationTime); + transferRedeemablesToOwner(); + selfdestruct(owner); + } + + // UTILS + + function getChainID() internal pure returns (uint256) { + uint256 id; + assembly { + id := chainid() + } + + return id; + } + + function validateExpiryDate(uint256 _expirationTime) internal view { + require(_expirationTime > block.timestamp, "expiration can't be in the past"); + } + + function validateExpired(uint256 _expirationTime) internal view { + require(block.timestamp >= _expirationTime, "not expired yet"); + } + + function validateRedeem(Redeem memory _redeem, uint256 _maxTxDelayInBlocks, uint256 _expirationTime, uint256 _startTime) internal view { + require(_redeem.blockNumber < block.number, "transaction cannot be in the future"); + require(_redeem.blockNumber >= (block.number - _maxTxDelayInBlocks), "transaction too old"); + require(_redeem.blockHash == blockhash(_redeem.blockNumber), "invalid block hash"); + + require(block.timestamp < _expirationTime, "expired gift"); + require(block.timestamp > _startTime, "reedeming not yet started"); + } + + function hashRedeem(Redeem memory _redeem) internal pure returns (bytes32) { + return keccak256(abi.encode( + REDEEM_TYPEHASH, + _redeem.blockNumber, + _redeem.blockHash, + _redeem.receiver, + _redeem.code + )); + } + + function recoverSigner(bytes32 _domainSeparator, Redeem memory _redeem, bytes memory _sig) internal pure returns(address) { + require(_sig.length == 65, "bad signature length"); + + bytes32 r; + bytes32 s; + uint8 v; + + assembly { + r := mload(add(_sig, 32)) + s := mload(add(_sig, 64)) + v := byte(0, mload(add(_sig, 96))) + } + + if (v < 27) { + v += 27; + } + + require(v == 27 || v == 28, "signature version doesn't match"); + + bytes32 digest = keccak256(abi.encodePacked( + "\x19\x01", + _domainSeparator, + hashRedeem(_redeem) + )); + + return ecrecover(digest, v, r, s); + } + + function validateCode(Redeem memory _redeem, bytes32 _code) internal pure { + bytes32 codeHash = keccak256(abi.encodePacked(_redeem.code)); + require(codeHash == _code, "invalid code"); + } +} diff --git a/contracts/GiftBucket.sol b/contracts/GiftBucket.sol index 102d3ca..aeda192 100644 --- a/contracts/GiftBucket.sol +++ b/contracts/GiftBucket.sol @@ -1,67 +1,19 @@ pragma solidity ^0.6.1; pragma experimental ABIEncoderV2; +import "./Bucket.sol"; import "./erc20/IERC20.sol"; -import "./RedeemUtil.sol"; - -contract GiftBucket { - - bool initialized; - - address payable public owner; - - IERC20 public tokenContract; - - uint256 public expirationTime; - uint256 public startTime; - - uint256 constant maxTxDelayInBlocks = 10; - - struct Gift { - address recipient; - uint256 amount; - bytes32 code; - } - - mapping(address => Gift) public gifts; +contract GiftBucket is Bucket { uint256 public redeemableSupply; - bytes32 constant EIP712DOMAIN_TYPEHASH = keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); - bytes32 DOMAIN_SEPARATOR; - - modifier onlyOwner() { - require(msg.sender == owner, "owner required"); - _; - } - - constructor(address _tokenAddress, uint256 _startTime, uint256 _expirationTime) public { - initialize(_tokenAddress, _startTime, _expirationTime, msg.sender); - } - - function initialize(address _tokenAddress, uint256 _startTime, uint256 _expirationTime, address _owner) public { - require(initialized == false, "already initialized"); - - RedeemUtil.validateExpiryDate(_expirationTime); - - tokenContract = IERC20(_tokenAddress); - startTime = _startTime; - expirationTime = _expirationTime; - owner = payable(_owner); - - DOMAIN_SEPARATOR = keccak256(abi.encode( - EIP712DOMAIN_TYPEHASH, - keccak256("KeycardGift"), - keccak256("1"), - RedeemUtil.getChainID(), - address(this) - )); - - initialized = true; - } + constructor( + address _tokenAddress, + uint256 _startTime, + uint256 _expirationTime) Bucket("KeycardGift", _tokenAddress, _startTime, _expirationTime) public {} function totalSupply() public view returns(uint256) { - return tokenContract.balanceOf(address(this)); + return IERC20(tokenAddress).balanceOf(address(this)); } function availableSupply() public view returns(uint256) { @@ -81,41 +33,21 @@ contract GiftBucket { require(gift.recipient == address(0), "recipient already used"); gift.recipient = recipient; - gift.amount = amount; gift.code = code; + gift.data = amount; require(redeemableSupply + amount > redeemableSupply, "addition overflow"); redeemableSupply += amount; } - function redeem(RedeemUtil.Redeem calldata _redeem, bytes calldata _sig) external { - RedeemUtil.validateRedeem(_redeem, maxTxDelayInBlocks, expirationTime, startTime); - - address recipient = RedeemUtil.recoverSigner(DOMAIN_SEPARATOR, _redeem, _sig); - - Gift storage gift = gifts[recipient]; - require(gift.recipient == recipient, "not found"); - - RedeemUtil.validateCode(_redeem, gift.code); - - uint256 amount = gift.amount; - require(redeemableSupply >= amount, "not enough redeemable supply"); - - gift.recipient = address(0); - gift.amount = 0; - gift.code = 0; - - redeemableSupply -= amount; - - tokenContract.transfer(_redeem.receiver, amount); + function transferRedeemable(uint256 data, Redeem memory redeem) override internal { + require(redeemableSupply >= data, "not enough redeemable supply"); + redeemableSupply -= data; + IERC20(tokenAddress).transfer(redeem.receiver, data); } - function kill() external onlyOwner { - RedeemUtil.validateExpired(expirationTime); - - bool success = tokenContract.transfer(owner, this.totalSupply()); + function transferRedeemablesToOwner() override internal { + bool success = IERC20(tokenAddress).transfer(owner, this.totalSupply()); assert(success); - - selfdestruct(owner); } } diff --git a/contracts/GiftBucketFactory.sol b/contracts/GiftBucketFactory.sol index 8593fb1..77513ee 100644 --- a/contracts/GiftBucketFactory.sol +++ b/contracts/GiftBucketFactory.sol @@ -13,7 +13,7 @@ contract GiftBucketFactory { } function create(address _tokenAddress, uint256 _startTime, uint256 _expirationTime) public returns (address) { - address p = address(new Proxy(abi.encodeWithSelector(0x9e3d87cd, _tokenAddress, _startTime, _expirationTime, msg.sender), address(GiftBucketImplementation))); + address p = address(new Proxy(abi.encodeWithSelector(0x4e9464ed, "KeycardGift", _tokenAddress, _startTime, _expirationTime, msg.sender), address(GiftBucketImplementation))); emit BucketCreated(msg.sender, p); return p; } diff --git a/contracts/NFTBucket.sol b/contracts/NFTBucket.sol index 218342e..8c61c1d 100644 --- a/contracts/NFTBucket.sol +++ b/contracts/NFTBucket.sol @@ -1,91 +1,26 @@ pragma solidity ^0.6.1; pragma experimental ABIEncoderV2; +import "./Bucket.sol"; import "./erc721/IERC721.sol"; import "./erc721/IERC721Receiver.sol"; import "./erc721/IERC165.sol"; -import "./RedeemUtil.sol"; - -contract NFTBucket is IERC165, IERC721Receiver { - bool initialized; - - address payable public owner; - - IERC721 public tokenContract; - - uint256 public expirationTime; - uint256 public startTime; - - uint256 constant maxTxDelayInBlocks = 10; - - struct Gift { - address recipient; - uint256 tokenID; - bytes32 code; - } - - mapping(address => Gift) public gifts; +contract NFTBucket is Bucket, IERC165, IERC721Receiver { bytes4 private constant _ERC721_RECEIVED = 0x150b7a02; //bytes4(keccak256("onERC721Received(address,address,uint256,bytes)")) - bytes32 constant EIP712DOMAIN_TYPEHASH = keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); - bytes32 DOMAIN_SEPARATOR; + constructor( + address _tokenAddress, + uint256 _startTime, + uint256 _expirationTime) Bucket("KeycardNFTGift", _tokenAddress, _startTime, _expirationTime) public {} - modifier onlyOwner() { - require(msg.sender == owner, "owner required"); - _; + function transferRedeemable(uint256 data, Redeem memory redeem) override internal { + IERC721(tokenAddress).safeTransferFrom(address(this), redeem.receiver, data); } - constructor(address _tokenAddress, uint256 _startTime, uint256 _expirationTime) public { - initialize(_tokenAddress, _startTime, _expirationTime, msg.sender); - } - - function initialize(address _tokenAddress, uint256 _startTime, uint256 _expirationTime, address _owner) public { - require(initialized == false, "already initialized"); - - RedeemUtil.validateExpiryDate(_expirationTime); - - tokenContract = IERC721(_tokenAddress); - startTime = _startTime; - expirationTime = _expirationTime; - owner = payable(_owner); - - DOMAIN_SEPARATOR = keccak256(abi.encode( - EIP712DOMAIN_TYPEHASH, - keccak256("KeycardNFTGift"), - keccak256("1"), - RedeemUtil.getChainID(), - address(this) - )); - - initialized = true; - } - - function redeem(RedeemUtil.Redeem calldata _redeem, bytes calldata _sig) external { - RedeemUtil.validateRedeem(_redeem, maxTxDelayInBlocks, expirationTime, startTime); - - address recipient = RedeemUtil.recoverSigner(DOMAIN_SEPARATOR, _redeem, _sig); - - Gift storage gift = gifts[recipient]; - require(gift.recipient == recipient, "not found"); - - RedeemUtil.validateCode(_redeem, gift.code); - - uint256 tokenID = gift.tokenID; - gift.recipient = address(0); - gift.tokenID = 0; - gift.code = 0; - - tokenContract.safeTransferFrom(address(this), _redeem.receiver, tokenID); - } - - function kill() external onlyOwner { - RedeemUtil.validateExpired(expirationTime); - - tokenContract.setApprovalForAll(owner, true); - assert(tokenContract.isApprovedForAll(address(this), owner)); - - selfdestruct(owner); + function transferRedeemablesToOwner() override internal { + IERC721(tokenAddress).setApprovalForAll(owner, true); + assert(IERC721(tokenAddress).isApprovedForAll(address(this), owner)); } function supportsInterface(bytes4 interfaceID) external override(IERC165) view returns (bool) { @@ -93,7 +28,7 @@ contract NFTBucket is IERC165, IERC721Receiver { } function onERC721Received(address _operator, address _from, uint256 _tokenID, bytes calldata _data) external override(IERC721Receiver) returns(bytes4) { - require(msg.sender == address(tokenContract), "only the NFT contract can call this"); + require(msg.sender == tokenAddress, "only the NFT contract can call this"); require((_operator == owner) || (_from == owner), "only the owner can create gifts"); require(_data.length == 52, "invalid data field"); @@ -113,8 +48,8 @@ contract NFTBucket is IERC165, IERC721Receiver { require(gift.recipient == address(0), "recipient already used"); gift.recipient = recipient; - gift.tokenID = _tokenID; gift.code = code; + gift.data = _tokenID; return _ERC721_RECEIVED; } diff --git a/contracts/NFTBucketFactory.sol b/contracts/NFTBucketFactory.sol index 55add82..7d979f4 100644 --- a/contracts/NFTBucketFactory.sol +++ b/contracts/NFTBucketFactory.sol @@ -13,7 +13,7 @@ contract NFTBucketFactory { } function create(address _tokenAddress, uint256 _startTime, uint256 _expirationTime) public returns (address) { - address p = address(new Proxy(abi.encodeWithSelector(0x9e3d87cd, _tokenAddress, _startTime, _expirationTime, msg.sender), address(NFTBucketImplementation))); + address p = address(new Proxy(abi.encodeWithSelector(0x4e9464ed, "KeycardNFTGift", _tokenAddress, _startTime, _expirationTime, msg.sender), address(NFTBucketImplementation))); emit BucketCreated(msg.sender, p); return p; } diff --git a/contracts/RedeemUtil.sol b/contracts/RedeemUtil.sol index f17eccd..a2d4a86 100644 --- a/contracts/RedeemUtil.sol +++ b/contracts/RedeemUtil.sol @@ -2,81 +2,4 @@ pragma solidity ^0.6.1; pragma experimental ABIEncoderV2; library RedeemUtil { - struct Redeem { - uint256 blockNumber; - bytes32 blockHash; - address receiver; - bytes32 code; - } - - bytes32 constant REDEEM_TYPEHASH = keccak256("Redeem(uint256 blockNumber,bytes32 blockHash,address receiver,bytes32 code)"); - - function getChainID() internal pure returns (uint256) { - uint256 id; - assembly { - id := chainid() - } - - return id; - } - - function validateExpiryDate(uint256 _expirationTime) internal view { - require(_expirationTime > block.timestamp, "expiration can't be in the past"); - } - - function validateExpired(uint256 _expirationTime) internal view { - require(block.timestamp >= _expirationTime, "not expired yet"); - } - - function validateRedeem(Redeem memory _redeem, uint256 _maxTxDelayInBlocks, uint256 _expirationTime, uint256 _startTime) internal view { - require(_redeem.blockNumber < block.number, "transaction cannot be in the future"); - require(_redeem.blockNumber >= (block.number - _maxTxDelayInBlocks), "transaction too old"); - require(_redeem.blockHash == blockhash(_redeem.blockNumber), "invalid block hash"); - - require(block.timestamp < _expirationTime, "expired gift"); - require(block.timestamp > _startTime, "reedeming not yet started"); - } - - function hashRedeem(Redeem memory _redeem) internal pure returns (bytes32) { - return keccak256(abi.encode( - REDEEM_TYPEHASH, - _redeem.blockNumber, - _redeem.blockHash, - _redeem.receiver, - _redeem.code - )); - } - - function recoverSigner(bytes32 _domainSeparator, Redeem memory _redeem, bytes memory _sig) internal pure returns(address) { - require(_sig.length == 65, "bad signature length"); - - bytes32 r; - bytes32 s; - uint8 v; - - assembly { - r := mload(add(_sig, 32)) - s := mload(add(_sig, 64)) - v := byte(0, mload(add(_sig, 96))) - } - - if (v < 27) { - v += 27; - } - - require(v == 27 || v == 28, "signature version doesn't match"); - - bytes32 digest = keccak256(abi.encodePacked( - "\x19\x01", - _domainSeparator, - hashRedeem(_redeem) - )); - - return ecrecover(digest, v, r, s); - } - - function validateCode(Redeem memory _redeem, bytes32 _code) internal pure { - bytes32 codeHash = keccak256(abi.encodePacked(_redeem.code)); - require(codeHash == _code, "invalid code"); - } } diff --git a/test/contract_spec.js b/test/contract_spec.js index 64a72cf..d84fdbc 100644 --- a/test/contract_spec.js +++ b/test/contract_spec.js @@ -156,6 +156,22 @@ contract("GiftBucket", function () { }); }); + it("deploy bucket via factory", async () => { + const create = GiftBucketFactory.methods.create(TestToken._address, EXPIRATION_TIME); + const gas = await create.estimateGas(); + const receipt = await create.send({ + from: shop, + gas: gas, + }); + + const bucketAddress = receipt.events.BucketCreated.returnValues.bucket; + const jsonInterface = _GiftBucket.options.jsonInterface; + GiftBucket = new EmbarkJS.Blockchain.Contract({ + abi: jsonInterface, + address: bucketAddress, + }); + }); + it("shop buys 100 tokens", async function () { let supply = await TestToken.methods.totalSupply().call(); assert.equal(parseInt(supply), 0); @@ -264,7 +280,7 @@ contract("GiftBucket", function () { let initialRedeemableSupply = await GiftBucket.methods.redeemableSupply().call(); let gift = await GiftBucket.methods.gifts(recipient).call(); - const amount = parseInt(gift.amount); + const amount = parseInt(gift.data); const message = { blockNumber: blockNumber, @@ -293,7 +309,6 @@ contract("GiftBucket", function () { let expectedRedeemableSupply = initialRedeemableSupply - amount; let redeemableSupply = await GiftBucket.methods.redeemableSupply().call(); assert.equal(parseInt(redeemableSupply), expectedRedeemableSupply, `redeemableSupply after redeem should be ${expectedRedeemableSupply} instead of ${redeemableSupply}`); - } it("cannot redeem before start date", async function() { diff --git a/test/nft_contract_spec.js b/test/nft_contract_spec.js index c4e3e6d..ffd92c9 100644 --- a/test/nft_contract_spec.js +++ b/test/nft_contract_spec.js @@ -165,7 +165,7 @@ contract("NFTBucket", function () { async function checkGift(recipient, tokenID) { let gift = await NFTBucket.methods.gifts(recipient).call(); assert.equal(gift.recipient, recipient, "gift not found"); - assert.equal(parseInt(gift.tokenID), tokenID, "token ID does not match"); + assert.equal(parseInt(gift.data), tokenID, "token ID does not match"); let tokenOwner = await TestNFT.methods.ownerOf(tokenID).call(); assert.equal(tokenOwner, NFTBucket._address, "token owner is wrong"); } @@ -209,7 +209,7 @@ contract("NFTBucket", function () { async function testRedeem(receiver, recipient, signer, relayer, redeemCode, blockNumber, blockHash) { let gift = await NFTBucket.methods.gifts(recipient).call(); - const tokenID = gift.tokenID; + const tokenID = gift.data; const message = { blockNumber: blockNumber,