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

ERC4907 #370

Merged
merged 26 commits into from
Jul 17, 2022
Merged
Show file tree
Hide file tree
Changes from 16 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
18 changes: 15 additions & 3 deletions contracts/ERC721A.sol
Original file line number Diff line number Diff line change
Expand Up @@ -598,7 +598,7 @@ contract ERC721A is IERC721A {
/**
* @dev Returns whether the `approvedAddress` is equals to `from` or `msgSender`.
*/
function _isOwnerOrApproved(
function _isSinglyApprovedOrOwner(
Vectorized marked this conversation as resolved.
Show resolved Hide resolved
Vectorized marked this conversation as resolved.
Show resolved Hide resolved
address approvedAddress,
address from,
address msgSender
Expand All @@ -613,6 +613,18 @@ contract ERC721A is IERC721A {
}
}

/**
* @dev Returns whether `spender` is allowed to manage `tokenId`.
*
* Requirements:
*
* - `tokenId` must exist.
*/
function _isApprovedOrOwner(address spender, uint256 tokenId) internal view virtual returns (bool) {
Vectorized marked this conversation as resolved.
Show resolved Hide resolved
address owner = ownerOf(tokenId);
return (spender == owner || isApprovedForAll(owner, spender) || getApproved(tokenId) == spender);
}

/**
* @dev Transfers `tokenId` from `from` to `to`.
*
Expand All @@ -635,7 +647,7 @@ contract ERC721A is IERC721A {
(uint256 approvedAddressSlot, address approvedAddress) = _getApprovedAddress(tokenId);

// The nested ifs save around 20+ gas over a compound boolean condition.
if (!_isOwnerOrApproved(approvedAddress, from, _msgSenderERC721A()))
if (!_isSinglyApprovedOrOwner(approvedAddress, from, _msgSenderERC721A()))
if (!isApprovedForAll(from, _msgSenderERC721A())) revert TransferCallerNotOwnerNorApproved();

if (to == address(0)) revert TransferToZeroAddress();
Expand Down Expand Up @@ -712,7 +724,7 @@ contract ERC721A is IERC721A {

if (approvalCheck) {
// The nested ifs save around 20+ gas over a compound boolean condition.
if (!_isOwnerOrApproved(approvedAddress, from, _msgSenderERC721A()))
if (!_isSinglyApprovedOrOwner(approvedAddress, from, _msgSenderERC721A()))
if (!isApprovedForAll(from, _msgSenderERC721A())) revert TransferCallerNotOwnerNorApproved();
}

Expand Down
87 changes: 87 additions & 0 deletions contracts/extensions/ERC4907A.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// SPDX-License-Identifier: MIT
// ERC721A Contracts v4.1.0
// Creator: Chiru Labs

pragma solidity ^0.8.4;

import './IERC4907A.sol';
import '../ERC721A.sol';

/**
* @dev ERC4907 compliant extension of ERC721A.
*
* The ERC4907 standard https://eips.ethereum.org/EIPS/eip-4907[ERC4907] allows
* owners and authorized addresses to add a time-limited role
* with restricted permissions to ERC721 tokens.
*/
abstract contract ERC4907A is ERC721A, IERC4907A {
// The bit position of `expires` in packed user info.
uint256 private constant _BITPOS_EXPIRES = 160;

// Mapping from token ID to user info.
//
// Bits Layout:
// - [0..159] `user`
// - [160..223] `expires`
Vectorized marked this conversation as resolved.
Show resolved Hide resolved
mapping(uint256 => uint256) private _packedUserInfo;

/**
* @dev Sets the `user` and `expires` for `tokenId`.
* The zero address indicates there is no user.
*
* Requirements:
*
* - The caller must own `tokenId` or be an approved operator.
*/
function setUser(
uint256 tokenId,
address user,
uint64 expires
) public {
if (!_isApprovedOrOwner(msg.sender, tokenId)) revert SetUserCallerNotOwnerNorApproved();

_packedUserInfo[tokenId] = (uint256(expires) << _BITPOS_EXPIRES) | uint256(uint160(user));

emit UpdateUser(tokenId, user, expires);
}

/**
* @dev Returns the user address for `tokenId`.
* The zero address indicates that there is no user or if the user is expired.
*/
function userOf(uint256 tokenId) public view returns (address) {
uint256 packed = _packedUserInfo[tokenId];
assembly {
// Branchless `packed *= block.timestamp <= expires ? 1 : 0`.
Vectorized marked this conversation as resolved.
Show resolved Hide resolved
packed := mul(
packed,
// `block.timestamp <= expires ? 1 : 0`.
lt(shl(_BITPOS_EXPIRES, timestamp()), packed)
Vectorized marked this conversation as resolved.
Show resolved Hide resolved
)
}
return address(uint160(packed));
Vectorized marked this conversation as resolved.
Show resolved Hide resolved
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Explanation

First, we know that:

packed = (expires << 160) | user

The straightforward way of implementing block.timestamp <= expires is:

!(block.timestamp > (packed >> 160)) which in Yul, is: iszero(gt(timestamp(), shr(160, packed)))


Now, notice that if user != address(0):

(block.timestamp << 160) < ((expires << 160) | user) is true for block.timestamp <= expires.

And when user == address(0):

address(uint160((expires << 160) | user)) evaluates to address(0) anyway.

So that's how we avoid the extra iszero opcode.


Having an optimized userOf function is important for on-chain verification by third party contracts.


/**
* @dev Returns the user's expires of `tokenId`.
*/
function userExpires(uint256 tokenId) public view returns (uint256) {
return _packedUserInfo[tokenId] >> _BITPOS_EXPIRES;
}

/**
* @dev See {IERC165-supportsInterface}.
*/
function supportsInterface(bytes4 interfaceId) public view virtual override(ERC721A, IERC721A) returns (bool) {
// The interface ID for ERC4907 is `0xad092b5c`.
// See: https://eips.ethereum.org/EIPS/eip-4907
return super.supportsInterface(interfaceId) || interfaceId == 0xad092b5c;
}

/**
* @dev Returns the user address for `tokenId`, ignoring the expiry status.
*/
function _explicitUserOf(uint256 tokenId) internal view returns (address) {
return address(uint160(_packedUserInfo[tokenId]));
}
}
48 changes: 48 additions & 0 deletions contracts/extensions/IERC4907A.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// SPDX-License-Identifier: MIT
// ERC721A Contracts v4.1.0
// Creator: Chiru Labs

pragma solidity ^0.8.4;

import '../IERC721A.sol';

/**
* @dev Interface of an ERC4907A compliant contract.
*/
interface IERC4907A is IERC721A {
/**
* The caller must own the token or be an approved operator.
*/
error SetUserCallerNotOwnerNorApproved();

/**
* @dev Emitted when the `user` of an NFT or the `expires` of the `user` is changed.
* The zero address for user indicates that there is no user address.
*/
event UpdateUser(uint256 indexed tokenId, address indexed user, uint64 expires);

/**
* @dev Sets the `user` and `expires` for `tokenId`.
* The zero address indicates there is no user.
*
* Requirements:
*
* - The caller must own `tokenId` or be an approved operator.
*/
function setUser(
uint256 tokenId,
address user,
uint64 expires
) external;

/**
* @dev Returns the user address for `tokenId`.
* The zero address indicates that there is no user or if the user is expired.
*/
function userOf(uint256 tokenId) external view returns (address);

/**
* @dev Returns the user's expires of `tokenId`.
*/
function userExpires(uint256 tokenId) external view returns (uint256);
}
7 changes: 7 additions & 0 deletions contracts/interfaces/IERC4907A.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// SPDX-License-Identifier: MIT
// ERC721A Contracts v4.1.0
// Creator: Chiru Labs

pragma solidity ^0.8.4;

import '../extensions/IERC4907A.sol';
23 changes: 23 additions & 0 deletions contracts/mocks/ERC4907AMock.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// SPDX-License-Identifier: MIT
// ERC721A Contracts v4.1.0
// Creators: Chiru Labs

pragma solidity ^0.8.4;

import '../extensions/ERC4907A.sol';

contract ERC4907AMock is ERC721A, ERC4907A {
constructor(string memory name_, string memory symbol_) ERC721A(name_, symbol_) {}

function safeMint(address to, uint256 quantity) public {
Vectorized marked this conversation as resolved.
Show resolved Hide resolved
_safeMint(to, quantity);
}

function supportsInterface(bytes4 interfaceId) public view virtual override(ERC721A, ERC4907A) returns (bool) {
return super.supportsInterface(interfaceId);
}

function explicitUserOf(uint256 tokenId) public view returns (address) {
return _explicitUserOf(tokenId);
}
}
155 changes: 155 additions & 0 deletions test/extensions/ERC4907A.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
const { deployContract, getBlockTimestamp, mineBlockTimestamp } = require('../helpers.js');
const { expect } = require('chai');
const { constants } = require('@openzeppelin/test-helpers');
const { ZERO_ADDRESS } = constants;

const createTestSuite = ({ contract, constructorArgs }) =>
function () {
context(`${contract}`, function () {
beforeEach(async function () {
this.erc4097a = await deployContract(contract, constructorArgs);
});

describe('EIP-165 support', async function () {
it('supports ERC165', async function () {
expect(await this.erc4097a.supportsInterface('0x01ffc9a7')).to.eq(true);
});

it('supports IERC721', async function () {
expect(await this.erc4097a.supportsInterface('0x80ac58cd')).to.eq(true);
});

it('supports ERC721Metadata', async function () {
expect(await this.erc4097a.supportsInterface('0x5b5e139f')).to.eq(true);
});

it('supports ERC4907', async function () {
expect(await this.erc4097a.supportsInterface('0xad092b5c')).to.eq(true);
});

it('does not support random interface', async function () {
expect(await this.erc4097a.supportsInterface('0x00000042')).to.eq(false);
});
});

context('with minted tokens', async function () {
beforeEach(async function () {
const [owner, addr1] = await ethers.getSigners();
this.owner = owner;
this.addr1 = addr1;

await this.erc4097a['safeMint(address,uint256)'](this.owner.address, 1);
await this.erc4097a['safeMint(address,uint256)'](this.addr1.address, 2);

this.expires = (await getBlockTimestamp()) + 123;
this.tokenId = 2;
this.user = this.owner;
});

it('explicitUserOf returns zero address after minting', async function () {
expect(await this.erc4097a.explicitUserOf(0)).to.equal(ZERO_ADDRESS);
expect(await this.erc4097a.explicitUserOf(1)).to.equal(ZERO_ADDRESS);
expect(await this.erc4097a.explicitUserOf(2)).to.equal(ZERO_ADDRESS);
});

it('userOf returns zero address after minting', async function () {
expect(await this.erc4097a.userOf(0)).to.equal(ZERO_ADDRESS);
expect(await this.erc4097a.userOf(1)).to.equal(ZERO_ADDRESS);
expect(await this.erc4097a.userOf(2)).to.equal(ZERO_ADDRESS);
});

it('userExpires returns zero timestamp after minting', async function () {
expect(await this.erc4097a.userExpires(0)).to.equal(0);
expect(await this.erc4097a.userExpires(1)).to.equal(0);
expect(await this.erc4097a.userExpires(2)).to.equal(0);
});

describe('setUser', async function () {
beforeEach(async function () {
this.setUser = async () => await this.erc4097a.connect(this.addr1)
.setUser(this.tokenId, this.user.address, this.expires);

this.setupAuthTest = async () => {
this.tokenId = 0;
await expect(this.setUser()).to.be.revertedWith('SetUserCallerNotOwnerNorApproved');
};
});

it('correctly changes the return value of explicitUserOf', async function () {
await this.setUser();
expect(await this.erc4097a.explicitUserOf(this.tokenId)).to.equal(this.user.address);
});

it('correctly changes the return value of userOf', async function () {
await this.setUser();
expect(await this.erc4097a.userOf(this.tokenId)).to.equal(this.user.address);
});

it('correctly changes the return value of expires', async function () {
await this.setUser();
expect(await this.erc4097a.userExpires(this.tokenId)).to.equal(this.expires);
});

it('emits the UpdateUser event properly', async function () {
await expect(await this.setUser())
.to.emit(this.erc4097a, 'UpdateUser')
.withArgs(this.tokenId, this.user.address, this.expires);
});

it('reverts for an invalid token', async function () {
this.tokenId = 123;
await expect(this.setUser()).to.be.revertedWith('OwnerQueryForNonexistentToken');
});

it('requires token ownership', async function () {
await this.setupAuthTest();
await this.erc4097a.transferFrom(this.owner.address, this.addr1.address, this.tokenId);
await this.setUser();
});

it('requires token approval', async function () {
await this.setupAuthTest();
await this.erc4097a.approve(this.addr1.address, this.tokenId);
await this.setUser();
});

it('requires operator approval', async function () {
await this.setupAuthTest();
await this.erc4097a.setApprovalForAll(this.addr1.address, 1);
await this.setUser();
});
});

describe('after expiry', async function () {
beforeEach(async function () {
await this.erc4097a.connect(this.addr1)
.setUser(this.tokenId, this.user.address, this.expires);
});

it('userOf returns zero address after expires', async function () {
expect(await this.erc4097a.userOf(this.tokenId)).to.equal(this.user.address);
await mineBlockTimestamp(this.expires);
expect(await this.erc4097a.userOf(this.tokenId)).to.equal(this.user.address);
await mineBlockTimestamp(this.expires + 1);
expect(await this.erc4097a.userOf(this.tokenId)).to.equal(ZERO_ADDRESS);
});

it('explicitUserOf returns correct address after expiry', async function () {
expect(await this.erc4097a.explicitUserOf(this.tokenId)).to.equal(this.user.address);
await mineBlockTimestamp(this.expires);
expect(await this.erc4097a.explicitUserOf(this.tokenId)).to.equal(this.user.address);
await mineBlockTimestamp(this.expires + 1);
expect(await this.erc4097a.explicitUserOf(this.tokenId)).to.equal(this.user.address);
});
});
});
});
};

describe(
'ERC4907A',
createTestSuite({
contract: 'ERC4907AMock',
constructorArgs: ['Azuki', 'AZUKI'],
})
);