1

I am trying to familiarize myself with the Echidna fuzzer by writing a property test on an ERC721 contract with permit. Once I run echidna in docker using command npx hardhat clean && npx hardhat compile --force && echidna-test /src --contract EchidnaERC721OffchainPermit --config /src/contracts/echidna/config.yaml --corpus-dir /src/contracts/echidna/corpus the test fails because ECDSA.tryRecover() is not able to recover the address.

So, I added events to debug it and ECDSA.tryRecover() returns 0x0000000000000000000000000000000000000000 address due to RecoverError.InvalidSignatureS from ECDSA library. What is weird that my unit tests works perfectly. Only difference is that I use dapptools' cheatcode function hevm.sign() in Echidna test which returns already split signature (uint8 v, bytes32 r, bytes32 s) and in my contract, which I am testing, I work with whole signature instead. So, I put it together in my helper function createSignature() using abi.encodePacked(r, s, v) (see the EchidnaERC721OffchainPermit.sol).

Any idea how to solve this issue, please?

ERC721OffchainPermit.sol

//SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";

import "./IERC721OffchainPermit.sol";

error ERC721OffchainPermit__UnknownToken();
error ERC721OffchainPermit__PermitDeadlineExpired();
error ERC721OffchainPermit__ZeroAddress();
error ERC721OffchainPermit__InvalidPermitSignature();

contract ERC721OffchainPermit is ERC721 {

    bytes32 private constant DOMAIN_TYPE_HASH =
        keccak256(
            "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
        );
    bytes32 private constant PERMIT_TYPE_HASH =
        keccak256(
            "permitToApprove(address spender,uint256 tokenId,uint256 nonce,uint256 deadline)"
        );
    string private constant DOMAIN_VERSION = "1";

    bytes32 private immutable _domainSeparator;
    uint256 private immutable _domainChainId;
    uint256 private tokenCounter;

    // tokenId to nonce
    mapping(uint256 => uint256) private _nonces;

    // debugging events
    event PermitApplied(address spender, uint256 tokenId);
    event SafeTransferWithPermitExecuted(address from, address to, address sender);
    event Recovery(address recoveredAddress, address spender, ECDSA.RecoverError error);
    event DigestOutput(bytes32 _digest);
    event DigestInput(address spender, uint256 tokenId, uint256 nonce, uint256 deadline);

    constructor() ERC721("TEST NFT WITH PERMIT", "TNFTP") {
        uint256 chainId;
        //solhint-disable-next-line no-inline-assembly
        assembly {
            chainId := chainid()
        }
        _domainChainId = chainId;
        _domainSeparator = _calculateDomainSeparator(chainId);
    }

    ////////////////////
    // MAIN FUNCTIONS //
    ////////////////////

    function safeTransferFromAndPermit(
        address from,
        address to,
        uint256 tokenId,
        bytes memory _data,
        uint256 deadline,
        bytes memory signature
    ) external {
        // use the off-chain generated permit to get msg.sender approved
        permitToApprove(to, tokenId, deadline, signature);
        // use safeTransferFrom
        emit SafeTransferWithPermitExecuted(from, to, msg.sender);
        safeTransferFrom(from, to, tokenId, _data);
    }

    function permitToApprove(
        address spender,
        uint256 tokenId,
        uint256 deadline,
        bytes memory signature
    ) public {
        if (deadline < block.timestamp) {
            revert ERC721OffchainPermit__PermitDeadlineExpired();
        }
        emit DigestInput(spender, tokenId, _nonces[tokenId], deadline);
        bytes32 digest = _buildDigest(spender, tokenId, _nonces[tokenId], deadline);
        emit DigestOutput(digest);
        (address recoveredAddress, ECDSA.RecoverError err) = ECDSA.tryRecover(digest, signature);

        emit Recovery(recoveredAddress, spender, err);
        if (recoveredAddress == address(0) || spender == address(0)) {
            revert ERC721OffchainPermit__ZeroAddress();
        }
        if (
            !_isApprovedOrOwner(recoveredAddress, tokenId) ||
            !SignatureChecker.isValidSignatureNow(ownerOf(tokenId), digest, signature)
        ) {
            revert ERC721OffchainPermit__InvalidPermitSignature();
        }
        _approve(spender, tokenId);
        emit PermitApplied(spender, tokenId);
    }

    function _buildDigest(
        address spender,
        uint256 tokenId,
        uint256 nonce,
        uint256 deadline
    ) public view returns (bytes32) {
        return
            ECDSA.toTypedDataHash(
                getDomainSeparator(),
                keccak256(abi.encode(PERMIT_TYPE_HASH, spender, tokenId, nonce, deadline))
            );
    }

    function _incrementNonce(uint256 _tokenId) internal {
        _nonces[_tokenId]++;
    }

    function mint() public {
        uint256 tokenId = tokenCounter;
        tokenCounter++;
        _mint(msg.sender, tokenId);
    }

    function _transfer(
        address from,
        address to,
        uint256 tokenId
    ) internal virtual override {
        _incrementNonce(tokenId);
        super._transfer(from, to, tokenId);
    }

    /////////////
    // HELPERS //
    /////////////

    function _calculateDomainSeparator(uint256 _chainId) internal view returns (bytes32) {
        return
            keccak256(
                abi.encode(
                    DOMAIN_TYPE_HASH,
                    keccak256(bytes(name())),
                    keccak256(bytes(DOMAIN_VERSION)),
                    _chainId,
                    address(this)
                )
            );
    }

    /////////////
    // GETTERS //
    /////////////

    function getNonce(uint256 _tokenId) public view returns (uint256) {
        // _exists() inherited from ERC721.sol
        if (!_exists(_tokenId)) {
            revert ERC721OffchainPermit__UnknownToken();
        }
        return _nonces[_tokenId];
    }

    function getDomainSeparator() public view returns (bytes32) {
        uint256 chainId;
        //solhint-disable-next-line no-inline-assembly
        assembly {
            chainId := chainid()
        }

        return (chainId == _domainChainId) ? _domainSeparator : _calculateDomainSeparator(chainId);
    }

    function getTokenCounter() public view returns (uint256) {
        return tokenCounter;
    }

    function getVersion() public pure returns (string memory) {
        return DOMAIN_VERSION;
    }

    ///////////////
    // OVERRIDES //
    ///////////////

    function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) {
        return
            interfaceId == type(IERC721OffchainPermit).interfaceId || // 0x13787601
            super.supportsInterface(interfaceId);
    }
}

MockERC721OffchainPermit.sol

//SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;

import "../ERC721OffchainPermit.sol";

contract MockERC721OffchainPermit is ERC721OffchainPermit {
    function mint(address _to, uint256 _value) external {
        _mint(_to, _value);
    }
}

EchidnaERC721OffchainPermit.sol

//SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;

import "./MockERC721OffchainPermit.sol";

interface IHevm {
    function sign(uint256 sk, bytes32 digest)
        external
        returns (
            uint8 v,
            bytes32 r,
            bytes32 s
        );
}

contract EchidnaERC721OffchainPermit {
    MockERC721OffchainPermit asset;
    IHevm hevm;
    uint256 constant OWNER_PK = 2;
    // an address corresponded to private key `2`;
    address constant OWNER = 0x2B5AD5c4795c026514f8317c7a215E218DcCD6cF;

    event AssertionFailed(string reason);
    event EchidnaSaveTransferInfo(
        address from,
        address to,
        uint256 tokenId,
        uint256 deadline,
        uint256 blockTimestamp
    );
    event EchidnaLogEvent(string reason);
    event EchidnaDigestCreated(bytes32 digest);
    event EchidnaDigestSigned(uint8 v, bytes32 r, bytes32 s);
    event EchidnaSignatureGenerated(bytes signature);

    constructor() {
        asset = new MockERC721OffchainPermit();
        // cheatcodes which can manipulate the environment in which the execution is run
        // can be accessed from an address `0x7109709ECfa91a80626fF3989D68f67F5b1DD12D`
        // as Echidna implements hevm
        hevm = IHevm(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D);
    }

    // helper method to create signature
    function createSignature(
        address spender,
        uint256 tokenId,
        uint256 deadline
    ) public returns (bytes memory) {
        require(spender != address(0), "Spender cannot be zero address");
        require(deadline >= block.timestamp, "Deadline must be higher than current timestamp");
        require(asset.getNonce(tokenId) >= 0, "Token must be already minted");
        uint256 _nonce = asset.getNonce(tokenId);
        bytes32 _digest = asset._buildDigest(spender, tokenId, _nonce, deadline);
        emit EchidnaDigestCreated(_digest);
        (uint8 v, bytes32 r, bytes32 s) = hevm.sign(OWNER_PK, _digest);
        emit EchidnaDigestSigned(v, r, s);
        emit EchidnaSignatureGenerated(abi.encodePacked(r, s, v));
        return abi.encodePacked(r, s, v);
    }

    function testPermitToApprove(uint256 deadline) public {
        // 1. pre
        require(deadline > block.timestamp);
        // 2. setup
        // mint NFT
        asset.mint();
        // get its tokenId
        uint256 tokenId = asset.getTokenCounter() - 1;
        // transfer token to the OWNER
        asset.transferFrom(address(this), OWNER, tokenId);
        require(asset.ownerOf(tokenId) == OWNER, "Transfer failed");
        // create signature by the owner
        bytes memory signature = createSignature(address(this), tokenId, deadline);
        // 3. test
        // 3.1 test valid signature
        emit EchidnaLogEvent(">>> TEST 1 <<<");
        bool permitAndTransferSuccessful = false;
        emit EchidnaSaveTransferInfo(OWNER, address(this), tokenId, deadline, block.timestamp);

        try
            asset.safeTransferFromAndPermit(OWNER, address(this), tokenId, "", deadline, signature)
        {
            permitAndTransferSuccessful = true;
            emit EchidnaLogEvent(">>> TEST 1 SUCCESFULL <<<");
        } catch {
            emit AssertionFailed(">>> TEST 1 FAILED <<<");
        }
        // 3.2 cannot use signature twice
        emit EchidnaLogEvent(">>> TEST 2 <<<");
        if (permitAndTransferSuccessful) {
            try
                asset.safeTransferFromAndPermit(
                    OWNER,
                    address(this),
                    tokenId,
                    "",
                    deadline,
                    signature
                )
            {
                emit AssertionFailed(">>> TEST 2 FAILED <<<");
            } catch {
                emit EchidnaLogEvent(">>> TEST 2 SUCCESFULL <<<");
            }
        }
    }

    // added as otherwise Echidna keeps returning ""ERC721: transfer to non ERC721Receiver implementer"
    function onERC721Received(
        address operator,
        address from,
        uint256 tokenId,
        bytes calldata data
    ) external returns (bytes4) {
        return IERC721Receiver.onERC721Received.selector;
    }
}

config.yaml

testMode: assertion
testLimit: 50000
shrinkLimit: 1000

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.