EIP-712 Signature를 foundry에서 만들어보자! (feat. js 없는 Off-chain 서명)

frenchkebab·2023년 5월 10일
0

EIP / Open Source

목록 보기
5/9
post-thumbnail

출처: https://book.getfoundry.sh/tutorials/testing-eip712

Replay Attack에 대해 알아보다가, 정말 많은 것을 찾아보았다.

혹 EIP-712나 Off-Chain 서명에 대해 낯설다면 아래의 글들을 쭉 정독해보기 바란다.

  1. Openzeppelin의 ECSA library
  2. ERC-191
  3. EIP-712
  4. EIP-2612

대부분의 Off-chain 서명은 javascript를 이용하는데 foundry에서는 이것을 solidity로 test 할 수 있다.

한번 ARABOZA! 뿌우뿌우~~

[1] Permit

이 코드가 헷갈린다면 ERC-2612 글을 참조하세용

   /*//////////////////////////////////////////////////////////////
                             EIP-2612 LOGIC
    //////////////////////////////////////////////////////////////*/

    function permit(
        address owner,
        address spender,
        uint256 value,
        uint256 deadline,
        uint8 v,
        bytes32 r,
        bytes32 s
    ) public virtual {
        require(deadline >= block.timestamp, "PERMIT_DEADLINE_EXPIRED");

        // Unchecked because the only math done is incrementing
        // the owner's nonce which cannot realistically overflow.
        unchecked {
            address recoveredAddress = ecrecover(
                keccak256(
                    abi.encodePacked(
                        "\x19\x01",
                        DOMAIN_SEPARATOR(),
                        keccak256(
                            abi.encode(
                                keccak256(
                                    "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"
                                ),
                                owner,
                                spender,
                                value,
                                nonces[owner]++,
                                deadline
                            )
                        )
                    )
                ),
                v,
                r,
                s
            );

            require(recoveredAddress != address(0) && recoveredAddress == owner, "INVALID_SIGNER");

            allowance[recoveredAddress][spender] = value;
        }

        emit Approval(owner, spender, value);
    }

이게 뭐꼬?

			  keccak256(
                    abi.encodePacked(
                        "\x19\x01",
                        DOMAIN_SEPARATOR(),
                        keccak256(
                            abi.encode(
                                keccak256(
                                    "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"
                                ),
                                owner,
                                spender,
                                value,
                                nonces[owner]++,
                                deadline
                            )
                        )
                    )
                ),
                v,
                r,
                s
            );

코드가 상당히 복잡해보이지만, EIP-712를 살펴보면 이해할 수 있다.

기본적으로 Off-chain 서명의 verification은 다음과 같이 이루어진다.

Signer는 다음의 3가지를 Verifier에게 전달한다.

  1. message hash에 서명한 signature: v, r, s
  2. message hash에서 변하는 부분 (파라미터 값들)
    -> 변하지 않는 값들은 Verifier가 들고 있다.

VerifierSigner에게 받은 정보들로 다음의 과정으로 verify를 수행한다.

  1. Signer에게 받은 변하는 값들(파라미터 값) + 원래 들고 있던 변하지 않는 값들 (Domain Separator, typeHash)
    을 조합하여 Signer가 서명한 message hash와 동일한 message hash를 생성
  2. message hashsignature(v, r, s)ecrecover에 넣어 사용하여 Signer의 주소가 리턴되는지 확인

우리는 이 Permit 함수에서 Verify할 signature를 foundry에서 solidity code로 생성하는 것이 목표이다!

[2] SigUtils

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.13;

contract SigUtils {
    bytes32 internal DOMAIN_SEPARATOR;

    constructor(bytes32 _DOMAIN_SEPARATOR) {
        DOMAIN_SEPARATOR = _DOMAIN_SEPARATOR;
    }

    // keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)");
    bytes32 public constant PERMIT_TYPEHASH =
        0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9;

    struct Permit {
        address owner;
        address spender;
        uint256 value;
        uint256 nonce;
        uint256 deadline;
    }

    // computes the hash of a permit
    function getStructHash(Permit memory _permit)
        internal
        pure
        returns (bytes32)
    {
        return
            keccak256(
                abi.encode(
                    PERMIT_TYPEHASH,
                    _permit.owner,
                    _permit.spender,
                    _permit.value,
                    _permit.nonce,
                    _permit.deadline
                )
            );
    }

    // computes the hash of the fully encoded EIP-712 message for the domain, which can be used to recover the signer
    function getTypedDataHash(Permit memory _permit)
        public
        view
        returns (bytes32)
    {
        return
            keccak256(
                abi.encodePacked(
                    "\x19\x01",
                    DOMAIN_SEPARATOR,
                    getStructHash(_permit)
                )
            );
    }
}

서명할 message hash를 만들어줄 함수이다.

생성 시 DOMAIN_SEPERATOR를 넣어주기만 하면 알아서 짠 하고 만들어줄 것이다.

Signer는 getTypedDataHash에 struct만 넣어주면 짠 하고 hash를 return해준다.

여기까지는 On-Chain으로 당연히 가능하지만 서명한 message를 생성한 것일 뿐이다.

이후의 서명 과정은 일반적으로 javascript를 통해 진행하지만, 우리의 목표는 이것을 foundry를 이용하여 서명하는 것이다.

foundry에는 vm.sign()이라는 cheatcode가 있다.

[3] Signature를 만들어 test하기

function test_Permit() public {
        SigUtils.Permit memory permit = SigUtils.Permit({
            owner: owner,
            spender: spender,
            value: 1e18,
            nonce: 0,
            deadline: 1 days
        });
		
  		// 위의 SigUtils contract를 이용하여 digest(서명할 message)를 생성
        bytes32 digest = sigUtils.getTypedDataHash(permit);
		
  		// vm.sign을 이용하여 서명
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPrivateKey, digest);

        token.permit(
            permit.owner,
            permit.spender,
            permit.value,
            permit.deadline,
            v,
            r,
            s
        );

        assertEq(token.allowance(owner, spender), 1e18);
        assertEq(token.nonces(owner), 1);
    }

[4] 여러가지의 경우를 Test하기

위의 경우 정상적으로 verif가 된다는 scenario였고...

실제 test code를 작성하기 위해서 다양한 상황을 설정할 필요가 있다.

1. deadline이 초과한 경우 (vm.warp)

  function testRevert_ExpiredPermit() public {
        SigUtils.Permit memory permit = SigUtils.Permit({
            owner: owner,
            spender: spender,
            value: 1e18,
            nonce: token.nonces(owner),
            deadline: 1 days
        });

        bytes32 digest = sigUtils.getTypedDataHash(permit);

        (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPrivateKey, digest);
		
    	// deadline인 1 days보다 1초를 더한다
        vm.warp(1 days + 1 seconds); // fast forward one second past the deadline

        vm.expectRevert("PERMIT_DEADLINE_EXPIRED");
        token.permit(
            permit.owner,
            permit.spender,
            permit.value,
            permit.deadline,
            v,
            r,
            s
        );
    }

2. Valid한 Signer가 아닌 경우 (vm.sign)

: ecrecover의 리턴 주소가 Valid한 Signer가 아닌 경우를 테스트한다.

    function testRevert_InvalidSigner() public {
        SigUtils.Permit memory permit = SigUtils.Permit({
            owner: owner,
            spender: spender,
            value: 1e18,
            nonce: token.nonces(owner),
            deadline: 1 days
        });

        bytes32 digest = sigUtils.getTypedDataHash(permit);
		
      	// ownerPrivateKey가 아닌 spenderPrivateKey로 서명
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(spenderPrivateKey, digest); // spender signs owner's approval

        vm.expectRevert("INVALID_SIGNER");
        token.permit(
            permit.owner,
            permit.spender,
            permit.value,
            permit.deadline,
            v,
            r,
            s
        );
    }

3. Invalid한 nonce값 넣기

    function testRevert_InvalidNonce() public {
        SigUtils.Permit memory permit = SigUtils.Permit({
            owner: owner,
            spender: spender,
            value: 1e18,
            nonce: 1, // owner nonce stored on-chain is 0
            deadline: 1 days
        });

        bytes32 digest = sigUtils.getTypedDataHash(permit);

        (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPrivateKey, digest);

        vm.expectRevert("INVALID_SIGNER");
        token.permit(
            permit.owner,
            permit.spender,
            permit.value,
            permit.deadline,
            v,
            r,
            s
        );
    }

4. Signature Replay

    function testRevert_SignatureReplay() public {
        SigUtils.Permit memory permit = SigUtils.Permit({
            owner: owner,
            spender: spender,
            value: 1e18,
            nonce: 0,
            deadline: 1 days
        });

        bytes32 digest = sigUtils.getTypedDataHash(permit);

        (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPrivateKey, digest);

        token.permit(
            permit.owner,
            permit.spender,
            permit.value,
            permit.deadline,
            v,
            r,
            s
        );

        vm.expectRevert("INVALID_SIGNER");
        token.permit(
            permit.owner,
            permit.spender,
            permit.value,
            permit.deadline,
            v,
            r,
            s
        );
    }

INVALID_SIGNER 메세지가 왜나오나 해서 생각해보니 permit 함수에서 두 번째 호출 시에는 nonce값은 1인데, replay된 signature의 nonce 값은 0이라 address(0) 리턴되어서 그렇다.

마무리

사실 vm.sign을 사용해서 sign을 하는 과정만을 다루려고 하였으나 서치를 하다보니 여러가지 test case도 소개하게 되었다.

EIP-712 관련 글들을 보다보면 대부분 Javascript를 활용하여 서명하게 되는데, foundry를 사용하여 solidity 코드로 작성하는 방법에 대해 다루어 보았다.

profile
Blockchain Dev Journey

0개의 댓글