Skip to content

Commit

Permalink
Merge branch 'master' into feature/kycnft-eip712
Browse files Browse the repository at this point in the history
# Conflicts:
#	contracts/KycNFT.sol
#	test/KycNFT.js
  • Loading branch information
zZoMROT committed Jan 31, 2025
2 parents 73ff87a + 95ee348 commit f1f8fcf
Show file tree
Hide file tree
Showing 2 changed files with 91 additions and 42 deletions.
18 changes: 12 additions & 6 deletions contracts/KycNFT.sol
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ contract KycNFT is Ownable, ERC721Burnable, EIP712 {
error OnlyOneNFTPerAddress();
/// @notice Thrown when signature is incorrect.
error BadSignature();
/// @notice Thrown when signature is expired.
error SignatureExpired();

bytes32 public constant MINT_TYPEHASH = keccak256("Mint(address to,uint256 nonce,uint256 tokenId)");
bytes32 public constant TRANSFER_FROM_TYPEHASH = keccak256("TransferFrom(address to,uint256 nonce,uint256 tokenId)");
bytes32 public constant MINT_TYPEHASH = keccak256("Mint(address to,uint256 nonce,uint256 tokenId,uint256 deadline)");
bytes32 public constant TRANSFER_FROM_TYPEHASH = keccak256("TransferFrom(address to,uint256 nonce,uint256 tokenId,uint256 deadline)");

/// @notice Nonce for each token ID.
mapping(uint256 => uint256) public nonces;
Expand All @@ -29,8 +31,9 @@ contract KycNFT is Ownable, ERC721Burnable, EIP712 {
* @param tokenId The ID of the token.
* @param signature The signature to be verified.
*/
modifier onlyOwnerSignature(address to, uint256 tokenId, bytes calldata signature, bytes32 typeHash) {
bytes32 structHash = keccak256(abi.encode(typeHash, to, nonces[tokenId]++, tokenId));
modifier onlyOwnerSignature(address to, uint256 tokenId, uint256 deadline, bytes calldata signature, bytes32 typeHash) {
if (block.timestamp > deadline) revert SignatureExpired();
bytes32 structHash = keccak256(abi.encode(typeHash, to, nonces[tokenId], tokenId, deadline));
bytes32 hash = _hashTypedDataV4(structHash);
if (owner() != ECDSA.recover(hash, signature)) revert BadSignature();
_;
Expand Down Expand Up @@ -60,9 +63,10 @@ contract KycNFT is Ownable, ERC721Burnable, EIP712 {
* @param from The address to transfer the token from.
* @param to The address to transfer the token to.
* @param tokenId The ID of the token to be transferred.
* @param deadline The deadline for the signature.
* @param signature The signature of the owner permitting the transfer.
*/
function transferFrom(address from, address to, uint256 tokenId, bytes calldata signature) public onlyOwnerSignature(to, tokenId, signature, TRANSFER_FROM_TYPEHASH) {
function transferFrom(address from, address to, uint256 tokenId, uint256 deadline, bytes calldata signature) public onlyOwnerSignature(to, tokenId, deadline, signature, TRANSFER_FROM_TYPEHASH) {
_transfer(from, to, tokenId);
}

Expand All @@ -77,9 +81,10 @@ contract KycNFT is Ownable, ERC721Burnable, EIP712 {

/**
* @notice See {mint} method. This function using a valid owner's signature instead of only owner permission.
* @param deadline The deadline for the signature.
* @param signature The signature of the owner permitting the mint.
*/
function mint(address to, uint256 tokenId, bytes calldata signature) external onlyOwnerSignature(to, tokenId, signature, MINT_TYPEHASH) {
function mint(address to, uint256 tokenId, uint256 deadline, bytes calldata signature) external onlyOwnerSignature(to, tokenId, deadline, signature, MINT_TYPEHASH) {
_mint(to, tokenId);
}

Expand All @@ -97,6 +102,7 @@ contract KycNFT is Ownable, ERC721Burnable, EIP712 {

function _update(address to, uint256 tokenId, address auth) internal override returns (address) {
if (to != address(0) && balanceOf(to) > 0) revert OnlyOneNFTPerAddress();
nonces[tokenId]++;
return super._update(to, tokenId, auth);
}
}
115 changes: 79 additions & 36 deletions test/KycNFT.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@ const MINT = {
{ name: 'to', type: 'address' },
{ name: 'nonce', type: 'uint256' },
{ name: 'tokenId', type: 'uint256' },
{ name: 'deadline', type: 'uint256' },
],
};
const TRANSFER_FROM = {
TransferFrom: MINT.Mint,
};

async function signTokenId(types, eip712, nft, to, tokenId, signer, chainId) {
async function signTokenId(types, eip712, nft, to, tokenId, signer, chainId, deadline) {
const domain = {
name: eip712.name,
version: eip712.version,
Expand All @@ -25,14 +26,15 @@ async function signTokenId(types, eip712, nft, to, tokenId, signer, chainId) {
to,
nonce: await nft.nonces(tokenId),
tokenId,
deadline,
};
return signer.signTypedData(domain, types, values);
}

describe('KycNFT', function () {
async function initContracts() {
const chainId = await getChainId();
const [owner, alice, bob] = await ethers.getSigners();
const [owner, alice, bob, charlie] = await ethers.getSigners();
const tokenIds = {
owner: '0x00',
alice: '0x01',
Expand All @@ -44,7 +46,8 @@ describe('KycNFT', function () {
const nft = await deployContract('KycNFT', [eip712.name, 'KYC', eip712.version, owner]);
await nft.mint(owner, tokenIds.owner);
await nft.mint(bob, tokenIds.bob);
return { owner, alice, bob, nft, tokenIds, chainId, eip712 };
const deadline = '0xffffffff';
return { owner, alice, bob, charlie, nft, tokenIds, chainId, eip712, deadline };
}

describe('transferFrom', function () {
Expand Down Expand Up @@ -79,41 +82,65 @@ describe('KycNFT', function () {

describe('transferFrom with signature', function () {
it('should revert with signature by non-owner', async function () {
const { alice, bob, nft, tokenIds, chainId, eip712 } = await loadFixture(initContracts);
const signature = await signTokenId(TRANSFER_FROM, eip712, nft, alice.address, tokenIds.bob, bob, chainId);
await expect(nft.connect(bob)['transferFrom(address,address,uint256,bytes)'](bob, alice, tokenIds.bob, signature))
const { alice, bob, nft, tokenIds, chainId, eip712, deadline } = await loadFixture(initContracts);
const signature = await signTokenId(TRANSFER_FROM, eip712, nft, alice.address, tokenIds.bob, bob, chainId, deadline);
await expect(nft.connect(bob)['transferFrom(address,address,uint256,uint256,bytes)'](bob, alice, tokenIds.bob, deadline, signature))
.to.be.revertedWithCustomError(nft, 'BadSignature');
});

it('should work with signature by owner', async function () {
const { owner, bob, alice, nft, tokenIds, chainId, eip712 } = await loadFixture(initContracts);
const { owner, bob, alice, nft, tokenIds, chainId, eip712, deadline } = await loadFixture(initContracts);
const transferToken = tokenIds.bob;
const signature = await signTokenId(TRANSFER_FROM, eip712, nft, alice.address, transferToken, owner, chainId);
await nft.connect(bob)['transferFrom(address,address,uint256,bytes)'](bob, alice, transferToken, signature);
const signature = await signTokenId(TRANSFER_FROM, eip712, nft, alice.address, transferToken, owner, chainId, deadline);
await nft.connect(bob)['transferFrom(address,address,uint256,uint256,bytes)'](bob, alice, transferToken, deadline, signature);
expect(await nft.ownerOf(tokenIds.bob)).to.equal(alice.address);
});

it('should revert with signature by owner and transfer someone else\'s token', async function () {
const { owner, bob, alice, nft, tokenIds, chainId, eip712 } = await loadFixture(initContracts);
const { owner, bob, alice, nft, tokenIds, chainId, eip712, deadline } = await loadFixture(initContracts);
const transferToken = tokenIds.owner;
const signature = await signTokenId(TRANSFER_FROM, eip712, nft, alice.address, transferToken, owner, chainId);
await expect(nft.connect(bob)['transferFrom(address,address,uint256,bytes)'](bob, alice, transferToken, signature))
const signature = await signTokenId(TRANSFER_FROM, eip712, nft, alice.address, transferToken, owner, chainId, deadline);
await expect(nft.connect(bob)['transferFrom(address,address,uint256,uint256,bytes)'](bob, alice, transferToken, deadline, signature))
.to.be.revertedWithCustomError(nft, 'ERC721IncorrectOwner');
});

it('should revert when recipient account already has 1 nft', async function () {
const { owner, bob, nft, tokenIds, chainId, eip712 } = await loadFixture(initContracts);
const signature = await signTokenId(TRANSFER_FROM, eip712, nft, owner.address, tokenIds.bob, owner, chainId);
await expect(nft.connect(owner)['transferFrom(address,address,uint256,bytes)'](bob, owner, tokenIds.bob, signature))
const { owner, bob, nft, tokenIds, chainId, eip712, deadline } = await loadFixture(initContracts);
const signature = await signTokenId(TRANSFER_FROM, eip712, nft, owner.address, tokenIds.bob, owner, chainId, deadline);
await expect(nft.connect(owner)['transferFrom(address,address,uint256,uint256,bytes)'](bob, owner, tokenIds.bob, deadline, signature))
.to.be.revertedWithCustomError(nft, 'OnlyOneNFTPerAddress');
});

it('should revert when send non-existen token', async function () {
const { owner, alice, nft, tokenIds, chainId, eip712 } = await loadFixture(initContracts);
const signature = await signTokenId(TRANSFER_FROM, eip712, nft, alice.address, tokenIds.nonexist, owner, chainId);
await expect(nft.connect(owner)['transferFrom(address,address,uint256,bytes)'](owner, alice, tokenIds.nonexist, signature))
const { owner, alice, nft, tokenIds, chainId, eip712, deadline } = await loadFixture(initContracts);
const signature = await signTokenId(TRANSFER_FROM, eip712, nft, alice.address, tokenIds.nonexist, owner, chainId, deadline);
await expect(nft.connect(owner)['transferFrom(address,address,uint256,uint256,bytes)'](owner, alice, tokenIds.nonexist, deadline, signature))
.to.be.revertedWithCustomError(nft, 'ERC721NonexistentToken');
});

it('should revert after deadline expired', async function () {
const { alice, bob, nft, tokenIds, chainId, eip712 } = await loadFixture(initContracts);
const deadline = '0x01';
const signature = await signTokenId(TRANSFER_FROM, eip712, nft, alice.address, tokenIds.bob, bob, chainId, deadline);
await expect(nft.connect(bob)['transferFrom(address,address,uint256,uint256,bytes)'](bob, alice, tokenIds.bob, deadline, signature))
.to.be.revertedWithCustomError(nft, 'SignatureExpired');
});

it('should revert after another differrent transfer', async function () {
const { owner, alice, bob, charlie, nft, tokenIds, chainId, eip712, deadline } = await loadFixture(initContracts);
const signature = await signTokenId(TRANSFER_FROM, eip712, nft, alice.address, tokenIds.bob, bob, chainId, deadline);
await nft.connect(owner).transferFrom(bob, charlie, tokenIds.bob);
await expect(nft.connect(bob)['transferFrom(address,address,uint256,uint256,bytes)'](bob, alice, tokenIds.bob, deadline, signature))
.to.be.revertedWithCustomError(nft, 'BadSignature');
});

it('should revert after burning', async function () {
const { owner, alice, bob, nft, tokenIds, chainId, eip712, deadline } = await loadFixture(initContracts);
const signature = await signTokenId(TRANSFER_FROM, eip712, nft, alice.address, tokenIds.bob, bob, chainId, deadline);
await nft.connect(owner).burn(tokenIds.bob);
await expect(nft.connect(bob)['transferFrom(address,address,uint256,uint256,bytes)'](bob, alice, tokenIds.bob, deadline, signature))
.to.be.revertedWithCustomError(nft, 'BadSignature');
});
});

describe('mint', function () {
Expand Down Expand Up @@ -141,48 +168,64 @@ describe('KycNFT', function () {

describe('mint with signature', function () {
it('should revert with invalid signature', async function () {
const { owner, alice, nft, tokenIds, chainId, eip712 } = await loadFixture(initContracts);
const signature = await signTokenId(MINT, eip712, nft, alice.address, tokenIds.alice, owner, chainId);
const { owner, alice, nft, tokenIds, chainId, eip712, deadline } = await loadFixture(initContracts);
const signature = await signTokenId(MINT, eip712, nft, alice.address, tokenIds.alice, owner, chainId, deadline);
const invalidSignature = signature.substring(-2) + '00';
await expect(nft.connect(owner)['mint(address,uint256,bytes)'](alice, tokenIds.alice, invalidSignature))
await expect(nft.connect(owner)['mint(address,uint256,uint256,bytes)'](alice, tokenIds.alice, deadline, invalidSignature))
.to.be.revertedWithCustomError(nft, 'BadSignature');
});

it('should revert with invalid signature when frontrun and change to-address', async function () {
const { owner, bob, alice, nft, tokenIds, chainId, eip712 } = await loadFixture(initContracts);
const signature = await signTokenId(MINT, eip712, nft, alice.address, tokenIds.alice, owner, chainId);
const { owner, bob, alice, nft, tokenIds, chainId, eip712, deadline } = await loadFixture(initContracts);
const signature = await signTokenId(MINT, eip712, nft, alice.address, tokenIds.alice, owner, chainId, deadline);
const invalidSignature = signature.substring(-2) + '00';
await expect(nft.connect(owner)['mint(address,uint256,bytes)'](bob, tokenIds.alice, invalidSignature))
await expect(nft.connect(owner)['mint(address,uint256,uint256,bytes)'](bob, tokenIds.alice, deadline, invalidSignature))
.to.be.revertedWithCustomError(nft, 'BadSignature');
});

it('should revert with non-owner signature', async function () {
const { alice, bob, nft, tokenIds, chainId, eip712 } = await loadFixture(initContracts);
const signature = await signTokenId(MINT, eip712, nft, alice.address, tokenIds.alice, bob, chainId);
await expect(nft.connect(bob)['mint(address,uint256,bytes)'](alice, tokenIds.alice, signature))
const { alice, bob, nft, tokenIds, chainId, eip712, deadline } = await loadFixture(initContracts);
const signature = await signTokenId(MINT, eip712, nft, alice.address, tokenIds.alice, bob, chainId, deadline);
await expect(nft.connect(bob)['mint(address,uint256,uint256,bytes)'](alice, tokenIds.alice, deadline, signature))
.to.be.revertedWithCustomError(nft, 'BadSignature');
});

it('should work with owner signature', async function () {
const { owner, alice, bob, nft, tokenIds, chainId, eip712 } = await loadFixture(initContracts);
const signature = await signTokenId(MINT, eip712, nft, alice.address, tokenIds.alice, owner, chainId);
await nft.connect(bob)['mint(address,uint256,bytes)'](alice, tokenIds.alice, signature);
const { owner, alice, bob, nft, tokenIds, chainId, eip712, deadline } = await loadFixture(initContracts);
const signature = await signTokenId(MINT, eip712, nft, alice.address, tokenIds.alice, owner, chainId, deadline);
await nft.connect(bob)['mint(address,uint256,uint256,bytes)'](alice, tokenIds.alice, deadline, signature);
expect(await nft.ownerOf(tokenIds.alice)).to.equal(alice.address);
});

it('should revert when account already has 1 nft', async function () {
const { owner, bob, nft, tokenIds, chainId, eip712 } = await loadFixture(initContracts);
const signature = await signTokenId(MINT, eip712, nft, bob.address, tokenIds.another, owner, chainId);
await expect(nft.connect(owner)['mint(address,uint256,bytes)'](bob, tokenIds.another, signature))
const { owner, bob, nft, tokenIds, chainId, eip712, deadline } = await loadFixture(initContracts);
const signature = await signTokenId(MINT, eip712, nft, bob.address, tokenIds.another, owner, chainId, deadline);
await expect(nft.connect(owner)['mint(address,uint256,uint256,bytes)'](bob, tokenIds.another, deadline, signature))
.to.be.revertedWithCustomError(nft, 'OnlyOneNFTPerAddress');
});

it('should revert when tokenId already exist', async function () {
const { owner, alice, nft, tokenIds, chainId, eip712 } = await loadFixture(initContracts);
const signature = await signTokenId(MINT, eip712, nft, alice.address, tokenIds.bob, owner, chainId);
await expect(nft.connect(owner)['mint(address,uint256,bytes)'](alice, tokenIds.bob, signature))
const { owner, alice, nft, tokenIds, chainId, eip712, deadline } = await loadFixture(initContracts);
const signature = await signTokenId(MINT, eip712, nft, alice.address, tokenIds.bob, owner, chainId, deadline);
await expect(nft.connect(owner)['mint(address,uint256,uint256,bytes)'](alice, tokenIds.bob, deadline, signature))
.to.be.revertedWithCustomError(nft, 'ERC721InvalidSender');
});

it('should revert after deadline expired', async function () {
const { owner, alice, nft, tokenIds, chainId, eip712 } = await loadFixture(initContracts);
const deadline = '0x01';
const signature = await signTokenId(MINT, eip712, nft, alice.address, tokenIds.bob, owner, chainId, deadline);
await expect(nft.connect(owner)['mint(address,uint256,uint256,bytes)'](alice, tokenIds.bob, deadline, signature))
.to.be.revertedWithCustomError(nft, 'SignatureExpired');
});

it('should revert after burning', async function () {
const { owner, alice, nft, tokenIds, chainId, eip712, deadline } = await loadFixture(initContracts);
const signature = await signTokenId(MINT, eip712, nft, alice.address, tokenIds.bob, owner, chainId, deadline);
await nft.connect(owner).burn(tokenIds.bob);
await expect(nft.connect(owner)['mint(address,uint256,uint256,bytes)'](alice, tokenIds.bob, deadline, signature))
.to.be.revertedWithCustomError(nft, 'BadSignature');
});
});

describe('burn', function () {
Expand Down

0 comments on commit f1f8fcf

Please sign in to comment.