Skip to content

Commit

Permalink
feat(excubiae): add ERC721Excubia contract
Browse files Browse the repository at this point in the history
  • Loading branch information
0xjei committed Jun 27, 2024
1 parent 603a53d commit cff108f
Show file tree
Hide file tree
Showing 3 changed files with 237 additions and 0 deletions.
58 changes: 58 additions & 0 deletions packages/excubiae/contracts/extensions/ERC721Excubia.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0;

import {Excubia} from "../Excubia.sol";
import {IERC721} from "@openzeppelin/contracts/interfaces/IERC721.sol";

/// @title ERC721 Excubia Contract.
/// @notice This contract extends the Excubia contract to integrate with an ERC721 token.
/// This contract checks the ownership of an ERC721 token to permit access through the gate.
/// @dev The contract refers to a contract implementing the ERC721 standard to admit the owner of the token.
contract ERC721Excubia is Excubia {
/// @notice The ERC721 token contract interface.
IERC721 public immutable NFT;

/// @notice Mapping to track which token IDs have been registered by the contract to
/// avoid double checks with the same token ID.
mapping(uint256 => bool) public registeredTokenIds;

/// @notice Error thrown when the passerby is not the owner of the token.
error UnexpectedTokenOwner();

/// @notice Constructor to initialize with target ERC721 contract.
/// @param _erc721 The address of the ERC721 contract.
constructor(address _erc721) payable {
if (_erc721 == address(0)) revert ZeroAddress();

NFT = IERC721(_erc721);
}

/// @notice Internal function to handle the passing logic with check.
/// @dev Calls the parent `_pass` function and registers the NFT ID to avoid double checks.
/// @param passerby The address of the entity attempting to pass the gate.
/// @param data Additional data required for the check (e.g., encoded token ID).
function _pass(address passerby, bytes calldata data) internal override {
super._pass(passerby, data);

uint256 tokenId = abi.decode(data, (uint256));

// Avoiding double check of the same token ID.
if (registeredTokenIds[tokenId]) revert AlreadyRegistered();

registeredTokenIds[tokenId] = true;
}

/// @notice Internal function to handle the gate protection (token ownership check) logic.
/// @dev Checks if the passerby is the owner of the token.
/// @param passerby The address of the entity attempting to pass the gate.
/// @param data Additional data required for the check (e.g., encoded token ID).
/// @return True if the passerby owns the token, false otherwise.
function _check(address passerby, bytes calldata data) internal view override returns (bool) {
uint256 tokenId = abi.decode(data, (uint256));

// Check if the user owns the token.
if (!(NFT.ownerOf(tokenId) == passerby)) revert UnexpectedTokenOwner();

return true;
}
}
22 changes: 22 additions & 0 deletions packages/excubiae/contracts/test/MockERC721.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0;

import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";

/// @title Mock ERC721 Token Contract.
/// @notice This contract is a mock implementation of the ERC721 standard for testing purposes.
/// @dev It simulates the behavior of a real ERC721 contract by providing basic minting functionality.
contract MockERC721 is ERC721, Ownable(msg.sender) {
/// @notice A counter to keep track of the token IDs.
uint256 private _tokenIdCounter;

/// @notice Constructor to initialize the mock contract with a name and symbol.
constructor() payable ERC721("MockERC721Token", "MockERC721Token") {}

/// @notice Mints a new token and assigns it to the specified recipient.
/// @param recipient The address that will receive the minted token.
function mintAndGiveToken(address recipient) public onlyOwner {
_safeMint(recipient, _tokenIdCounter++);
}
}
157 changes: 157 additions & 0 deletions packages/excubiae/test/ERC721Excubia.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { expect } from "chai"
import { ethers } from "hardhat"
import { AbiCoder, Signer, ZeroAddress } from "ethers"
import { ERC721Excubia, ERC721Excubia__factory, MockERC721, MockERC721__factory } from "../typechain-types"

describe("ERC721Excubia", function () {
let MockERC721Contract: MockERC721__factory
let ERC721ExcubiaContract: ERC721Excubia__factory
let erc721Excubia: ERC721Excubia

let signer: Signer
let signerAddress: string

let gate: Signer
let gateAddress: string

let anotherTokenOwner: Signer
let anotherTokenOwnerAddress: string

let mockERC721: MockERC721
let mockERC721Address: string

const encodedValidTokenId = AbiCoder.defaultAbiCoder().encode(["uint256"], [0])
const encodedInvalidOwnerTokenId = AbiCoder.defaultAbiCoder().encode(["uint256"], [1])
const decodedValidTokenId = AbiCoder.defaultAbiCoder().decode(["uint256"], encodedValidTokenId)
const decodedInvalidOwnerTokenId = AbiCoder.defaultAbiCoder().decode(["uint256"], encodedInvalidOwnerTokenId)

before(async function () {
;[signer, gate, anotherTokenOwner] = await ethers.getSigners()
signerAddress = await signer.getAddress()
gateAddress = await gate.getAddress()
anotherTokenOwnerAddress = await anotherTokenOwner.getAddress()

MockERC721Contract = await ethers.getContractFactory("MockERC721")
mockERC721 = await MockERC721Contract.deploy()
mockERC721Address = await mockERC721.getAddress()

// assign to `signerAddress` token with id equal to `1`.
await mockERC721.mintAndGiveToken(signerAddress)
await mockERC721.mintAndGiveToken(anotherTokenOwnerAddress)

ERC721ExcubiaContract = await ethers.getContractFactory("ERC721Excubia")
erc721Excubia = await ERC721ExcubiaContract.deploy(mockERC721Address)
})

describe("constructor()", function () {
it("Should deploy the ERC721Excubia contract correctly", async function () {
expect(erc721Excubia).to.not.eq(undefined)
})

it("Should deploy the MockERC721 contract correctly", async function () {
expect(mockERC721).to.not.eq(undefined)
})

it("Should fail to deploy ERC721Excubia when erc721 parameter is not valid", async () => {
await expect(ERC721ExcubiaContract.deploy(ZeroAddress)).to.be.revertedWithCustomError(
erc721Excubia,
"ZeroAddress"
)
})
})

describe("setGate()", function () {
it("should fail to set the gate when the caller is not the owner", async () => {
const [, notOwnerSigner] = await ethers.getSigners()

await expect(erc721Excubia.connect(notOwnerSigner).setGate(gateAddress)).to.be.revertedWithCustomError(
erc721Excubia,
"OwnableUnauthorizedAccount"
)
})

it("should fail to set the gate when the gate address is zero", async () => {
await expect(erc721Excubia.setGate(ZeroAddress)).to.be.revertedWithCustomError(erc721Excubia, "ZeroAddress")
})

it("Should set the gate contract address correctly", async function () {
const tx = await erc721Excubia.setGate(gateAddress)
const receipt = await tx.wait()
const event = ERC721ExcubiaContract.interface.parseLog(
receipt?.logs[0] as unknown as { topics: string[]; data: string }
) as unknown as {
args: {
gate: string
}
}

expect(receipt?.status).to.eq(1)
expect(event.args.gate).to.eq(gateAddress)
expect(await erc721Excubia.gate()).to.eq(gateAddress)
})

it("Should fail to set the gate if already set", async function () {
await expect(erc721Excubia.setGate(gateAddress)).to.be.revertedWithCustomError(
erc721Excubia,
"GateAlreadySet"
)
})
})

describe("check()", function () {
it("should throw when the token id is not owned by the correct recipient", async () => {
expect(await mockERC721.ownerOf(encodedInvalidOwnerTokenId)).to.be.equal(anotherTokenOwnerAddress)

await expect(erc721Excubia.check(signerAddress, encodedInvalidOwnerTokenId)).to.be.revertedWithCustomError(
erc721Excubia,
"UnexpectedTokenOwner"
)
})

it("should pass the check", async () => {
const passed = await erc721Excubia.check(signerAddress, encodedValidTokenId)

expect(passed).to.be.true
// check does NOT change the state of the contract (see pass()).
expect(await erc721Excubia.registeredTokenIds(decodedValidTokenId[0])).to.be.false
})
})

describe("pass()", function () {
it("should throw when the callee is not the gate", async () => {
await expect(
erc721Excubia.connect(signer).pass(signerAddress, encodedInvalidOwnerTokenId)
).to.be.revertedWithCustomError(erc721Excubia, "GateOnly")
})

it("should throw when the token id is not owned by the correct recipient", async () => {
await expect(
erc721Excubia.connect(gate).pass(signerAddress, encodedInvalidOwnerTokenId)
).to.be.revertedWithCustomError(erc721Excubia, "UnexpectedTokenOwner")
})

it("should pass the check", async () => {
const tx = await erc721Excubia.connect(gate).pass(signerAddress, encodedValidTokenId)
const receipt = await tx.wait()
const event = ERC721ExcubiaContract.interface.parseLog(
receipt?.logs[0] as unknown as { topics: string[]; data: string }
) as unknown as {
args: {
passerby: string
gate: string
}
}

expect(receipt?.status).to.eq(1)
expect(event.args.passerby).to.eq(signerAddress)
expect(event.args.gate).to.eq(gateAddress)
expect(await erc721Excubia.registeredTokenIds(decodedValidTokenId[0])).to.be.true
})

it("should prevent to pass twice", async () => {
await expect(
erc721Excubia.connect(gate).pass(signerAddress, encodedValidTokenId)
).to.be.revertedWithCustomError(erc721Excubia, "AlreadyRegistered")
})
})
})

0 comments on commit cff108f

Please sign in to comment.