diff --git a/contracts/NFTBucket.sol b/contracts/NFTBucket.sol new file mode 100644 index 0000000..83bad7a --- /dev/null +++ b/contracts/NFTBucket.sol @@ -0,0 +1,174 @@ +pragma solidity ^0.6.1; +pragma experimental ABIEncoderV2; + +import "./erc721/IERC721.sol"; +import "./erc721/IERC721Receiver.sol"; +import "./erc721/IERC165.sol"; + +contract NFTBucket is IERC165, IERC721Receiver { + + bool initialized; + + address payable public owner; + + IERC721 public tokenContract; + + uint256 public expirationTime; + + uint256 constant maxTxDelayInBlocks = 10; + + struct Gift { + address recipient; + uint256 tokenID; + bytes32 code; + } + + mapping(address => Gift) public gifts; + + struct Redeem { + uint256 blockNumber; + bytes32 blockHash; + address receiver; + bytes32 code; + } + + 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 constant REDEEM_TYPEHASH = keccak256("Redeem(uint256 blockNumber,bytes32 blockHash,address receiver,bytes32 code)"); + bytes32 DOMAIN_SEPARATOR; + + modifier onlyOwner() { + require(msg.sender == owner, "owner required"); + _; + } + + constructor(address _tokenAddress, uint256 _expirationTime) public { + initialize(_tokenAddress, _expirationTime, msg.sender); + } + + function initialize(address _tokenAddress, uint256 _expirationTime, address _owner) public { + require(initialized == false, "already initialized"); + + require(_expirationTime > block.timestamp, "expiration can't be in the past"); + + tokenContract = IERC721(_tokenAddress); + expirationTime = _expirationTime; + owner = payable(_owner); + + DOMAIN_SEPARATOR = keccak256(abi.encode( + EIP712DOMAIN_TYPEHASH, + keccak256("KeycardNFTGift"), + keccak256("1"), + _getChainID(), + address(this) + )); + + initialized = true; + } + + function _getChainID() internal pure returns (uint256) { + uint256 id; + assembly { + id := chainid() + } + + return id; + } + + function redeem(Redeem calldata _redeem, bytes calldata sig) external { + 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"); + + address recipient = recoverSigner(_redeem, sig); + + Gift storage gift = gifts[recipient]; + require(gift.recipient == recipient, "not found"); + + bytes32 codeHash = keccak256(abi.encodePacked(_redeem.code)); + require(codeHash == gift.code, "invalid code"); + + tokenContract.safeTransferFrom(address(this), _redeem.receiver, gift.tokenID); + } + + function kill() external onlyOwner { + require(block.timestamp >= expirationTime, "not expired yet"); + + tokenContract.setApprovalForAll(owner, true); + assert(tokenContract.isApprovedForAll(address(this), owner)); + + selfdestruct(owner); + } + + 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(Redeem memory _redeem, bytes memory sig) internal view 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", + DOMAIN_SEPARATOR, + hashRedeem(_redeem) + )); + + return ecrecover(digest, v, r, s); + } + + function supportsInterface(bytes4 interfaceID) external override(IERC165) view returns (bool) { + return interfaceID == _ERC721_RECEIVED; + } + + 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((_operator == owner) || (_from == owner), "only the owner can create gifts"); + require(_data.length == 52, "invalid data field"); + + bytes memory d = _data; + bytes32 tmp; + bytes32 code; + + assembly { + // tmp is 12 bytes of padding (taken from the array length) + 20 bytes of address + tmp := mload(add(d, 20)) + code := mload(add(d, 52)) + } + + address recipient = address(uint160(uint256(tmp))); + + Gift storage gift = gifts[recipient]; + require(gift.recipient == address(0), "recipient already used"); + + gift.recipient = recipient; + gift.tokenID = _tokenID; + gift.code = code; + + return _ERC721_RECEIVED; + } +} diff --git a/contracts/NFTBucketFactory.sol b/contracts/NFTBucketFactory.sol new file mode 100644 index 0000000..f9a08fa --- /dev/null +++ b/contracts/NFTBucketFactory.sol @@ -0,0 +1,20 @@ +pragma solidity ^0.6.1; + +import "./NFTBucket.sol"; +import "./Proxy.sol"; + +contract NFTBucketFactory { + NFTBucket public NFTBucketImplementation; + + event BucketCreated(address indexed gifter, address indexed bucket); + + constructor() public { + NFTBucketImplementation = new NFTBucket(address(0), block.timestamp + 1); + } + + function create(address _tokenAddress, uint256 _expirationTime) public returns (address) { + address p = address(new Proxy(abi.encodeWithSelector(0xc350a1b5, _tokenAddress, _expirationTime, msg.sender), address(NFTBucketImplementation))); + emit BucketCreated(msg.sender, p); + return p; + } +} diff --git a/contracts/erc721/IERC165.sol b/contracts/erc721/IERC165.sol new file mode 100644 index 0000000..f4e686f --- /dev/null +++ b/contracts/erc721/IERC165.sol @@ -0,0 +1,5 @@ +pragma solidity ^0.6.1; + +interface IERC165 { + function supportsInterface(bytes4 interfaceId) external view returns (bool); +} diff --git a/contracts/erc721/IERC721.sol b/contracts/erc721/IERC721.sol new file mode 100644 index 0000000..fa09845 --- /dev/null +++ b/contracts/erc721/IERC721.sol @@ -0,0 +1,25 @@ +pragma solidity ^0.6.1; + +/** + * @dev Required interface of an ERC721 compliant contract. + */ +interface IERC721 { + event Transfer(address indexed from, address indexed to, uint256 indexed tokenId); + event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId); + event ApprovalForAll(address indexed owner, address indexed operator, bool approved); + + function balanceOf(address owner) external view returns (uint256 balance); + + function ownerOf(uint256 tokenId) external view returns (address owner); + + function safeTransferFrom(address from, address to, uint256 tokenId) external; + + function transferFrom(address from, address to, uint256 tokenId) external; + function approve(address to, uint256 tokenId) external; + function getApproved(uint256 tokenId) external view returns (address operator); + + function setApprovalForAll(address operator, bool _approved) external; + function isApprovedForAll(address owner, address operator) external view returns (bool); + + function safeTransferFrom(address from, address to, uint256 tokenId, bytes calldata data) external; +} \ No newline at end of file diff --git a/contracts/erc721/IERC721Receiver.sol b/contracts/erc721/IERC721Receiver.sol new file mode 100644 index 0000000..f598294 --- /dev/null +++ b/contracts/erc721/IERC721Receiver.sol @@ -0,0 +1,5 @@ +pragma solidity ^0.6.1; + +interface IERC721Receiver { + function onERC721Received(address operator, address from, uint256 tokenId, bytes calldata data) external returns (bytes4); +} \ No newline at end of file diff --git a/contracts/erc721/TestNFT.sol b/contracts/erc721/TestNFT.sol new file mode 100644 index 0000000..e462fa2 --- /dev/null +++ b/contracts/erc721/TestNFT.sol @@ -0,0 +1,137 @@ +pragma solidity ^0.6.1; + +import "./IERC721.sol"; +import "./IERC721Receiver.sol"; +import "./IERC165.sol"; + +contract TestNFT is IERC165, IERC721 { + bytes4 private constant _ERC721_RECEIVED = 0x150b7a02; + + mapping (uint256 => address) private _tokenOwner; + mapping (uint256 => address) private _tokenApprovals; + mapping (address => uint256) private _ownedTokensCount; + mapping (address => mapping (address => bool)) private _operatorApprovals; + + bytes4 private constant _INTERFACE_ID_ERC721 = 0x80ac58cd; + + function balanceOf(address owner) public override(IERC721) view returns (uint256) { + require(owner != address(0), "ERC721: balance query for the zero address"); + return _ownedTokensCount[owner]; + } + + function ownerOf(uint256 tokenId) public override(IERC721) view returns (address) { + address owner = _tokenOwner[tokenId]; + require(owner != address(0), "ERC721: owner query for nonexistent token"); + + return owner; + } + + function approve(address to, uint256 tokenId) public override(IERC721) { + address owner = ownerOf(tokenId); + require(to != owner, "ERC721: approval to current owner"); + + require(msg.sender == owner || isApprovedForAll(owner, msg.sender), + "ERC721: approve caller is not owner nor approved for all" + ); + + _tokenApprovals[tokenId] = to; + emit Approval(owner, to, tokenId); + } + + function getApproved(uint256 tokenId) public override(IERC721) view returns (address) { + require(_exists(tokenId), "ERC721: approved query for nonexistent token"); + + return _tokenApprovals[tokenId]; + } + + function setApprovalForAll(address to, bool approved) public override(IERC721) { + require(to != msg.sender, "ERC721: approve to caller"); + + _operatorApprovals[msg.sender][to] = approved; + emit ApprovalForAll(msg.sender, to, approved); + } + + function isApprovedForAll(address owner, address operator) public override(IERC721) view returns (bool) { + return _operatorApprovals[owner][operator]; + } + + function transferFrom(address from, address to, uint256 tokenId) public override(IERC721) { + require(_isApprovedOrOwner(msg.sender, tokenId), "ERC721: transfer caller is not owner nor approved"); + + _transferFrom(from, to, tokenId); + } + + function safeTransferFrom(address from, address to, uint256 tokenId) public override(IERC721) { + safeTransferFrom(from, to, tokenId, ""); + } + + function safeTransferFrom(address from, address to, uint256 tokenId, bytes memory _data) public override(IERC721) { + transferFrom(from, to, tokenId); + require(_checkOnERC721Received(from, to, tokenId, _data), "ERC721: transfer to non ERC721Receiver implementer"); + } + + function _exists(uint256 tokenId) internal view returns (bool) { + address owner = _tokenOwner[tokenId]; + return owner != address(0); + } + + function _isApprovedOrOwner(address spender, uint256 tokenId) internal view returns (bool) { + require(_exists(tokenId), "ERC721: operator query for nonexistent token"); + address owner = ownerOf(tokenId); + return (spender == owner || getApproved(tokenId) == spender || isApprovedForAll(owner, spender)); + } + + function mint(address to, uint256 tokenId) public returns (bool) { + return mint(to, tokenId, ""); + } + + function mint(address to, uint256 tokenId, bytes memory _data) public returns (bool) { + require(to != address(0), "ERC721: mint to the zero address"); + require(!_exists(tokenId), "ERC721: token already minted"); + + _tokenOwner[tokenId] = to; + _ownedTokensCount[to]++; + + emit Transfer(address(0), to, tokenId); + require(_checkOnERC721Received(msg.sender, to, tokenId, _data), "ERC721: transfer to non ERC721Receiver implementer"); + } + + function _transferFrom(address from, address to, uint256 tokenId) internal { + require(ownerOf(tokenId) == from, "ERC721: transfer of token that is not own"); + require(to != address(0), "ERC721: transfer to the zero address"); + + _clearApproval(tokenId); + + _ownedTokensCount[from]--; + _ownedTokensCount[to]++; + + _tokenOwner[tokenId] = to; + + emit Transfer(from, to, tokenId); + } + + function _checkOnERC721Received(address from, address to, uint256 tokenId, bytes memory _data) internal returns (bool) { + if (!isContract(to)) { + return true; + } + + bytes4 retval = IERC721Receiver(to).onERC721Received(msg.sender, from, tokenId, _data); + return (retval == _ERC721_RECEIVED); + } + + function _clearApproval(uint256 tokenId) private { + if (_tokenApprovals[tokenId] != address(0)) { + _tokenApprovals[tokenId] = address(0); + } + } + + function isContract(address account) internal view returns (bool) { + uint256 size; + assembly { size := extcodesize(account) } + return size > 0; + } + + function supportsInterface(bytes4 interfaceID) external override(IERC165) view returns (bool) { + return interfaceID == _INTERFACE_ID_ERC721; + } +} \ No newline at end of file diff --git a/scripts/create-gift.js b/scripts/create-gift.js index 7651bcf..8a67392 100644 --- a/scripts/create-gift.js +++ b/scripts/create-gift.js @@ -4,15 +4,19 @@ import Web3 from 'web3'; import parseArgs from 'minimist'; import fs from 'fs'; -const argv = parseArgs(process.argv.slice(2), {boolean: ["deploy-factory", "deploy-bucket"], string: ["sender", "factory", "bucket", "token"], default: {"endpoint": "ws://127.0.0.1:8546", "validity-days": 365}}); +const argv = parseArgs(process.argv.slice(2), {boolean: ["nft", "deploy-factory", "deploy-bucket"], string: ["sender", "factory", "bucket", "token"], default: {"endpoint": "ws://127.0.0.1:8546", "validity-days": 365}}); const web3 = new Web3(argv["endpoint"]); -const GiftBucketConfig = loadEmbarkArtifact('./embarkArtifacts/contracts/GiftBucket.js'); -const GiftBucketFactoryConfig = loadEmbarkArtifact('./embarkArtifacts/contracts/GiftBucketFactory.js'); +const classPrefix = argv["nft"] ? "NFT" : "Gift"; -const GiftBucketFactory = new web3.eth.Contract(GiftBucketFactoryConfig["abiDefinition"]); -const GiftBucket = new web3.eth.Contract(GiftBucketConfig["abiDefinition"]); +const BucketConfig = loadEmbarkArtifact(`./embarkArtifacts/contracts/${classPrefix}Bucket.js`); +const BucketFactoryConfig = loadEmbarkArtifact(`./embarkArtifacts/contracts/${classPrefix}BucketFactory.js`); +const IERC721 = loadEmbarkArtifact(`./embarkArtifacts/contracts/IERC721.js`); + +const BucketFactory = new web3.eth.Contract(BucketFactoryConfig["abiDefinition"]); +const Bucket = new web3.eth.Contract(BucketConfig["abiDefinition"]); +const ERC721 = new web3.eth.Contract(IERC721["abiDefinition"]); function loadEmbarkArtifact(path) { let file = fs.readFileSync(path, "utf-8"); @@ -49,8 +53,8 @@ async function sendMethod(methodCall, sender, to) { } async function deployFactory(sender) { - let code = "0x" + GiftBucketFactoryConfig["code"]; - let methodCall = GiftBucketFactory.deploy({data: code}); + let code = "0x" + BucketFactoryConfig["code"]; + let methodCall = BucketFactory.deploy({data: code}); let receipt = await sendMethod(methodCall, sender, null); return receipt.contractAddress; } @@ -59,11 +63,11 @@ async function deployBucket(sender, factory, token, validityInDays) { let now = Math.round(new Date().getTime() / 1000); let expirationDate = now + (60 * 60 * 24 * validityInDays); - GiftBucketFactory.options.address = factory; - let methodCall = GiftBucketFactory.methods.create(token.toLowerCase(), expirationDate); + BucketFactory.options.address = factory; + let methodCall = BucketFactory.methods.create(token.toLowerCase(), expirationDate); try { - let receipt = await sendMethod(methodCall, sender, GiftBucketFactory.options.address); + let receipt = await sendMethod(methodCall, sender, BucketFactory.options.address); return receipt.events.BucketCreated.returnValues.bucket; } catch(err) { console.error(err); @@ -72,11 +76,38 @@ async function deployBucket(sender, factory, token, validityInDays) { } async function createGift(sender, bucket, keycard) { - GiftBucket.options.address = bucket; - let methodCall = GiftBucket.methods.createGift(keycard.keycard, keycard.amount, keycard.code); + Bucket.options.address = bucket; + let methodCall = Bucket.methods.createGift(keycard.keycard, keycard.amount, keycard.code); try { - let receipt = await sendMethod(methodCall, sender, GiftBucket.options.address); + let receipt = await sendMethod(methodCall, sender, Bucket.options.address); + return receipt; + } catch(err) { + console.error(err); + return null; + } +} + +function createNFTData(keycard, code) { + return keycard.toLowerCase() + code.replace("0x", ""); +} + +function senderAddress(sender) { + if (typeof(sender) == "string") { + return sender; + } else { + return sender.address; + } +} + +async function transferNFT(sender, token, bucket, keycard) { + Bucket.options.address = bucket; + ERC721.options.address = token ? token : await Bucket.methods.tokenContract().call(); + + let methodCall = ERC721.methods.safeTransferFrom(senderAddress(sender), bucket, keycard.amount, createNFTData(keycard.keycard, keycard.code)); + + try { + let receipt = await sendMethod(methodCall, sender, ERC721.options.address); return receipt; } catch(err) { console.error(err); @@ -90,8 +121,9 @@ function processLine(line) { } async function run() { - GiftBucketFactory.transactionConfirmationBlocks = 3; - GiftBucket.transactionConfirmationBlocks = 3; + BucketFactory.transactionConfirmationBlocks = 3; + Bucket.transactionConfirmationBlocks = 3; + ERC721.transactionConfirmationBlocks = 3; let sender; let hasDoneSomething = false; @@ -152,7 +184,7 @@ async function run() { let file = fs.readFileSync(argv["file"], 'utf8'); keycards = file.split("\n").map(processLine); for (let keycard of keycards) { - await createGift(sender, bucket, keycard) + await argv["nft"] ? createGift(sender, bucket, keycard) : transferNFT(sender, argv["token"], bucket, keycard); } } else if (!hasDoneSomething) { console.error("the --file option must be specified"); diff --git a/test/nft_contract_spec.js b/test/nft_contract_spec.js new file mode 100644 index 0000000..2f1d0cf --- /dev/null +++ b/test/nft_contract_spec.js @@ -0,0 +1,326 @@ +const EmbarkJS = artifacts.require('EmbarkJS'); +const TestNFT = artifacts.require('TestNFT'); +const _NFTBucket = artifacts.require('NFTBucket'); +const NFTBucketFactory = artifacts.require('NFTBucketFactory'); + +const TOTAL_SUPPLY = 10000; +const GIFT_AMOUNT = 10; +const REDEEM_CODE = web3.utils.sha3("hello world"); +const NOW = Math.round(new Date().getTime() / 1000); +const EXPIRATION_TIME = NOW + 60 * 60 * 24; // in 24 hours +const NEW_EXPIRATION_TIME = EXPIRATION_TIME + 60; + +let shop, + user, + relayer, + keycard_1, + keycard_2; + +config({ + contracts: { + deploy: { + "TestNFT": { + args: [], + }, + "NFTBucket": { + args: ["$TestNFT", EXPIRATION_TIME], + }, + "NFTBucketFactory": { + args: [], + }, + } + }, +}, (_err, _accounts) => { + shop = _accounts[0]; + user = _accounts[1]; + relayer = _accounts[2]; + keycard_1 = _accounts[3]; + keycard_2 = _accounts[4]; + keycard_3 = _accounts[5]; +}); + +let sendMethod; + +async function signRedeem(contractAddress, signer, message) { + const result = await web3.eth.net.getId(); + let chainId = parseInt(result); + //FIXME: in tests, getChainID in the contract returns 1 so we hardcode it here to 1. + chainId = 1; + + const domain = [ + { name: "name", type: "string" }, + { name: "version", type: "string" }, + { name: "chainId", type: "uint256" }, + { name: "verifyingContract", type: "address" } + ]; + + const redeem = [ + { name: "blockNumber", type: "uint256" }, + { name: "blockHash", type: "bytes32" }, + { name: "receiver", type: "address" }, + { name: "code", type: "bytes32" }, + ]; + + const domainData = { + name: "KeycardNFTGift", + version: "1", + chainId: chainId, + verifyingContract: contractAddress + }; + + const data = { + types: { + EIP712Domain: domain, + Redeem: redeem, + }, + primaryType: "Redeem", + domain: domainData, + message: message + }; + + return new Promise((resolve, reject) => { + sendMethod({ + jsonrpc: '2.0', + id: Date.now().toString().substring(9), + method: "eth_signTypedData", + params: [signer, data], + from: signer + }, (error, res) => { + if (error) { + return reject(error); + } + resolve(res.result); + }); + }); +} + +function mineAt(timestamp) { + return new Promise((resolve, reject) => { + sendMethod({ + jsonrpc: '2.0', + method: "evm_mine", + params: [timestamp], + id: Date.now().toString().substring(9) + }, (error, res) => { + if (error) { + return reject(error); + } + resolve(res.result); + }); + }); +} + +if (assert.match === undefined) { + assert.match = (message, pattern) => { + assert(pattern.test(message), `${message} doesn't match ${pattern}`); + } +} + +contract("NFTBucket", function () { + let NFTBucket; + + sendMethod = (web3.currentProvider.sendAsync) ? web3.currentProvider.sendAsync.bind(web3.currentProvider) : web3.currentProvider.send.bind(web3.currentProvider); + + it("deploy factory", async () => { + // only to test gas + const deploy = NFTBucketFactory.deploy({ + arguments: [] + }); + + const gas = await deploy.estimateGas(); + await deploy.send({ gas }) + }); + + it("deploy bucket", async () => { + // only to test gas + const deploy = _NFTBucket.deploy({ + arguments: [TestNFT._address, EXPIRATION_TIME] + }); + + const gas = await deploy.estimateGas(); + await deploy.send({ gas }) + }); + + it("deploy bucket via factory", async () => { + const create = NFTBucketFactory.methods.create(TestNFT._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 = _NFTBucket.options.jsonInterface; + NFTBucket = new EmbarkJS.Blockchain.Contract({ + abi: jsonInterface, + address: bucketAddress, + }); + }); + + function createGiftData(recipient) { + const redeemCodeHash = web3.utils.sha3(REDEEM_CODE); + return recipient + redeemCodeHash.replace("0x", ""); + } + + 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"); + let tokenOwner = await TestNFT.methods.ownerOf(tokenID).call(); + assert.equal(tokenOwner, NFTBucket._address, "token owner is wrong"); + } + + it("mint directly to gift", async function () { + await TestNFT.methods.mint(NFTBucket._address, 42, createGiftData(keycard_1)).send({ + from: shop, + }); + + await checkGift(keycard_1, 42); + }); + + it("transfer token from shop", async function() { + await TestNFT.methods.mint(shop, 0xcafe).send({from: shop,}); + await TestNFT.methods.safeTransferFrom(shop, NFTBucket._address, 0xcafe, createGiftData(keycard_2)).send({from: shop}); + + await checkGift(keycard_2, 0xcafe); + }); + + it("cannot create two gifts for the same recipient", async function() { + await TestNFT.methods.mint(shop, 43).send({from: shop}); + + try { + await TestNFT.methods.safeTransferFrom(shop, NFTBucket._address, 43, createGiftData(keycard_2)).send({from: shop}); + assert.fail("transfer should have failed"); + } catch(e) { + assert.match(e.message, /already used/); + } + + }); + + it("cannot create two gifts for the same token", async function() { + try { + await NFTBucket.methods.onERC721Received(shop, shop, 0xcafe, createGiftData(keycard_3)).send({from: shop}); + assert.fail("transfer should have failed"); + } catch(e) { + assert.match(e.message, /only the NFT/); + } + + }); + + async function testRedeem(receiver, recipient, signer, relayer, redeemCode, blockNumber, blockHash) { + let gift = await NFTBucket.methods.gifts(recipient).call(); + const tokenID = gift.tokenID; + + const message = { + blockNumber: blockNumber, + blockHash: blockHash, + receiver: receiver, + code: redeemCode, + }; + + const sig = await signRedeem(NFTBucket._address, signer, message); + const redeem = NFTBucket.methods.redeem(message, sig); + const redeemGas = await redeem.estimateGas(); + await redeem.send({ + from: relayer, + gas: redeemGas, + }); + + let tokenOwner = await TestNFT.methods.ownerOf(tokenID).call(); + assert.equal(tokenOwner, receiver, `Token owner is ${tokenOwner} instead of the expected ${receiver}`); + } + + it("cannot redeem after expiration date", async function() { + const block = await web3.eth.getBlock("latest"); + await mineAt(EXPIRATION_TIME); + + try { + await testRedeem(user, keycard_1, keycard_1, relayer, REDEEM_CODE, block.number, block.hash); + assert.fail("redeem should have failed"); + } catch(e) { + assert.match(e.message, /expired/); + } + }); + + it("cannot redeem with invalid code", async function() { + const block = await web3.eth.getBlock("latest"); + await mineAt(NOW); + try { + await testRedeem(user, keycard_1, keycard_1, relayer, web3.utils.sha3("bad-code"), block.number, block.hash); + assert.fail("redeem should have failed"); + } catch(e) { + assert.match(e.message, /invalid code/); + } + }); + + it("cannot redeem with invalid recipient", async function() { + const block = await web3.eth.getBlock("latest"); + await mineAt(NOW); + try { + await testRedeem(user, keycard_1, keycard_3, relayer, REDEEM_CODE, block.number, block.hash); + assert.fail("redeem should have failed"); + } catch(e) { + assert.match(e.message, /not found/); + } + }); + + it("cannot redeem with a block in the future", async function() { + const block = await web3.eth.getBlock("latest"); + await mineAt(NOW); + try { + await testRedeem(user, keycard_1, keycard_1, relayer, REDEEM_CODE, (block.number + 2), "0x0000000000000000000000000000000000000000000000000000000000000000"); + } catch (e) { + assert.match(e.message, /future/); + } + }); + + it("cannot redeem with an old block", async function() { + const currentBlock = await web3.eth.getBlock("latest"); + const block = await web3.eth.getBlock(currentBlock.number - 10); + + await mineAt(NOW); + try { + await testRedeem(user, keycard_1, keycard_1, relayer, REDEEM_CODE, block.number, block.hash); + } catch (e) { + assert.match(e.message, /too old/); + } + }); + + it("cannot redeem with an invalid hash", async function() { + const block = await web3.eth.getBlock("latest"); + + await mineAt(NOW); + try { + await testRedeem(user, keycard_1, keycard_1, relayer, REDEEM_CODE, block.number, "0x0000000000000000000000000000000000000000000000000000000000000000"); + } catch (e) { + assert.match(e.message, /invalid block hash/); + } + }); + + it("can redeem before expiration date", async function() { + const block = await web3.eth.getBlock("latest"); + await mineAt(NOW); + await testRedeem(user, keycard_1, keycard_1, relayer, REDEEM_CODE, block.number, block.hash); + }); + + async function testKill() { + assert(!await TestNFT.methods.isApprovedForAll(NFTBucket._address, shop).call(), `${shop} should not be the operator of bucket's tokens`); + await NFTBucket.methods.kill().send({from: shop}); + assert(await TestNFT.methods.isApprovedForAll(NFTBucket._address, shop).call(), `${shop} should become the operator of the destroyed bucket's tokens`); + } + + it("shop cannot kill contract before expirationTime", async function() { + await mineAt(NOW); + try { + await testKill(); + assert.fail("redeem should have failed"); + } catch(e) { + assert.match(e.message, /not expired yet/); + } + }); + + it("shop can kill contract after expirationTime", async function() { + await mineAt(EXPIRATION_TIME); + await testKill(); + }); +});