EIP-712, 서명 데이터를 이용해보자 [정리 / Solidity]

알락·2023년 3월 24일
6

블록체인 DApp 개발을 몇 차례 진행해봤다.

‘Opensea’라는 NFT 오픈 마켓을 클론 코딩했었고, 이후 CDS 금융파생상품을 컨트랙트로 만들어 서비스를 제공해주는 프로젝트를 진행했었다.

두 프로젝트를 끝마치면서 각각 아쉬운 점이 있었다.

우선 Opensea 클론코딩에서는 일 주일이라는 시간이 조금 모자라는 바람에, 사실상 오픈 마켓이 아닌 NFT 발행 서비스로 마감을 하게되었다. 하지만 본질적으로 Opensea가 제공하고 있는 거래서비스를 만들기 위해서는 Atomic Transaction을 구현해야하기 때문에 시간이 더 주어졌어도 엄청난 공부가 필요했을 것이다. Atomic Transaction은 이미 서명된 트랜잭션을 이용해야 한다.

CDS 프로젝트를 진행할 때는, 하나의 컨트랙트 기능을 제공받기 위해서 클라이언트가 트랜잭션 생성을 2번이나 해야한다는 문제가 생겼다. ERC20 토큰을 컨트랙트를 통해 다른 누군가에게 전송해야 하는데, 이더리움 상에서 컨트랙트도 명백히 법인(?)이어서 컨트랙트가 토큰을 옮길 수가 없었다. 그래서 토큰에 대한 사용 권한을 주는 ERC20의 approve 함수를 먼저 호출해서 컨트랙트 주소에 할당을 하고, 비로소 컨트랙트에서 함수를 호출할 수 있었다. 이후 얼마간 지나서야 안 사실이지만 많은 ERC20 토큰에서 permit 이라는 함수를 구현하며 미리 서명된 데이터를 이용하는 방법으로 이 문제를 풀 수 있었다.

두 프로젝트의 개선사항으로 하나의 공통점이 있다. 그것은 바로 미리 서명된 데이터를 이용하는 것이다. 미리 서명된 데이터를 이용하는 방법은 이미 블록체인 신에서는 널리 사용되고 있는 방식이다. 이 때문에 이후에도 분명 사용할 일이 있을 것이라고 생각하는 동시에, 내 프로젝트들도 개선을 해보고자 방법을 찾아봤다.

EIP-712

배경

[sign 함수 문제점]

사실 내가 프로젝트를 진행하면서 서명된 데이터를 아예 이용하지 않은 것은 아니다.

CDS 프로젝트에서 메타마스크를 통해 유저 로그인 기능을 구현하면서 서명된 데이터를 사용하였다. 그 때 사용했던 함수는 sign 이다. 한동안 이 함수를 통해서 트랜잭션을 미리 서명해두는 방식을 사용해왔던 것 같다. 하지만 해당 함수를 통해서 서명을 하게 되면 위와 같이 사용자가 어떤 데이터에 서명하고 있는지 알아보기 힘든 상황에 직면하게 된다. 신뢰하는 서비스, 사이트를 이용하더라도 서명하는 데이터를 변경해서 악의적인 트랜잭션에 서명하게 하면 사용자는 큰 타격을 입게 된다. 이 때문에 이더리움에서 EIP-712 가 제안되었다.

EIP-712는 Typed structured data hashing and signing라는 부제 명시되어있다. 직역하자면 타입이 지정되고 구조화되어있는 데이터의 해싱과 서명이라는 뜻이다.

  • 타입이 지정되어 있고
  • 구조화되어 있는

위의 두 사항을 잘 인지하고 살펴나가면 좋을 것 같다.

예시

[UniSwap의 ERC20 Permit 함수]

function permit(address owner, address spender, uint value, uint deadline, uint8 v, bytes32 r, bytes32 s) external {
    require(deadline >= block.timestamp, 'UniswapV2: EXPIRED');
    bytes32 digest = keccak256(
        abi.encodePacked(
            '\x19\x01',
            DOMAIN_SEPARATOR,
            keccak256(abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline))
        )
    );
    address recoveredAddress = ecrecover(digest, v, r, s);
    require(recoveredAddress != address(0) && recoveredAddress == owner, 'UniswapV2: INVALID_SIGNATURE');
    _approve(owner, spender, value);
}

본격적으로 들어가기 이전, 서명된 데이터가 사용되고 있는 예시를 가져와봤다. 간단히 해당 함수에 대해서 설명을 해보자면, 유니스왑의 유동성풀에 예치했던 자산을 제거할 때 발행했던 토큰을 해당 유저에게서 소각을 하기 위해 사용하는 함수다.

우선 핵심적인 코드는 다음과 같다.

address recoveredAddress = ecrecover(digest, v, r, s);
require(recoveredAddress != address(0) && recoveredAddress == owner, 'UniswapV2: INVALID_SIGNATURE');
_approve(owner, spender, value);

서명할 때의 데이터와, 서명 후 계산이 되는 v, r, s 값들로 ecrecover 함수를 통해 복호화하면 서명한 계정의 Public Address가 반환된다. 이 값이 permit 함수를 호출할 때 전달된 인자 owner 와 같다면 approve 함수가 최종 호출된다.

사실 이번에 내가 정리하고자 하는 EIP-712는 핵심코드 이전에 진행되는 재료준비에 해당한다. 즉 ecrecover 함수에 들어가는 값들을 만들어내는 것이 주목표이다.

Typed structured Data: S

EIP-712에서는 원래 서명 가능한 메시지는 트랜잭션과 8단위 bytestring이 전부였는데, 이 개선안을 통해서 Structured Data(이하, S)가 포함되었다고 설명한다. 이를 다음과 같이 표현한다.

T B8 ST \ \cup B^8 \ \cup S

서명데이터에 대한 인코딩은 다음의 형식을 따른다.

encode(domainSeparator : 𝔹²⁵⁶, message : 𝕊) = "\x19\x01" ‖ domainSeparator ‖ hashStruct(message)

S의 예시에 대해서는 다음과 같이 소개하고 있다. Solidity나 기타 언어에서도 흔히 볼 수 있는 구조체의 모습과 동일하다.

struct Mail {
	address from;
	address to;
	address contents;
}

S에 대해서 다음과 같이 정의한다.

struct Type 은 이름과 같은 유효한 식별자를 가지고 있고, 0개 이상의 멤버 변수를 가진다. 멤버 변수들은 type과 이름이 지정된다.

member type은 Atomic type, 동적 타입, 참조타입을 가질 수 있다.

Atomic Type은 bytes1에서 bytes32, uint8~uint256, int8~int256, bool, address 이다.

bytes, string은 동적 타입이다. 타입 선언은 atomic types과 비슷하지만, 인코딩은 다른 형식으로 행해진다.

참조타입은 배열과 구조체를 갖는다. 배열은 고정된 사이즈이거나 동적일 수 있으며, 보통 Type[n] 혹은 Type[]으로 나타낸다. 구조체는 이름을 통해서 다른 구조체를 참조한다. 표준은 재귀적 타입을 지원한다.
→ 재귀적 타입은 타입 안에 타입을 또 지정해서 사용할 수 있다는 뜻.

구조화된 데이터 S 집합은 모든 Struct Type의 인스턴스들을 포함할 수 있다.

hashStruct 함수

이더리움 컨트랙트를 확인해보면 흔하게 hashStruct 함수를 확인할 수 있다. hashStruct는 위에서 설명한 S를 컨트랙트에서 표준에 맞게 해싱을 해주는 함수다. 정의는 다음과 같다.

hashStruct(s : 𝕊) = keccak256(typeHash ‖ encodeData(s)) where typeHash = keccak256(encodeType(typeOf(s)))

구조체 타입 자체에 대한 해시값과 실질적인 데이터의 인코딩된 값을 또 해싱을 했을 대 비로소 우리가 궁극적으로 만들고자 하는 값에 한 발짝 다가갈 수 있다.

근데 또 필요한 값들이 추가되었다. typeHash와 encodeData이다. 이에 대해서도 정의가 되고 있다.

typeHash 정의

typeHash는 말그대로 type에 대해서 해싱을 한 값이다. 위의 예시에서 살펴봤을 때 Mail에 대한 구조체는 address from, address to, string contents 이렇게 3개의 멤버 변수가를 갖는다. 그래서 손수 규격에 맞게 인코딩을 하면 다음과 같이 할 수 있다.

Mail(address from,address to,string contents)
💡 여기서 띄어쓰기와 대소문자 구별은 매우 중요하다.

제안서에서 정의하는 인코딩은 방식은 다음과 같다.

name ‖ "(" ‖ member₁ ‖ "," ‖ member₂ ‖ "," ‖ … ‖ memberₙ ")"

Data 인코딩

실질적인 데이터의 인코딩은 각 값들을 그저 나열하기만 하면된다. 왜 이렇게 구현을 하고 있는지에 대해서 심화적인 내용을 원한다면 RLP 인코딩에 대해서 알아보면 좋을 것이다. 각 값들을 인코딩하는 방법은 해당 값이 어떤 데이터형을 가지고 있는지에 따라서 다르다.

  • Boolean은 uint256의 형태로 0과 1로 변경한다.
  • Address는 uint160을 따른다.
  • 정수값은 256bit로 확장되고, Big endian의 형식을 따른다.
  • bytes1에서 bytes31은 index[0]에서 부터 index[length-1]로 끝나는 배열의 형식을 갖는다.
  • bytes32에 해당할 때까지 0으로 채워진다.
  • bytes나 string 같은 Dynamic Value는 keccak256으로 내용을 해싱하여 인코딩한다.
  • 배열은 각각의 아이템에 대해서 해당 type에 대한 위 사항대로 인코딩을 진행하고 keccak256으로 해싱된다.

제안서를 그대로 직의역해봤다. 이더리움의 컨트랙트를 실행할 때 전달되는 트랜잭션 data 값을 확인했었던 사람들에게는 어렵지 않게 이해가 될 것이다 . 이 부분은 그렇게 하기로 약속한 것이기 때문에 ‘왜’에 대해서 고민하는 것보단 설명하는데로 데이터를 인코딩하는 데에 집중하는 것이 좋을 것이다.

여기까지가 hashStruct 함수가 반환하는 값을 준비하기 위해 필요한 재료들이다.

이제 남은 것은 domainSeparator 재료를 준비하는 것이다.

domainSeparator 정의

우선 domainSeparator에 들어가는 내용에 대해서 살펴보겠다.

  • string name : 유저가 읽을 수 있는 도메인이다. DApp이나 프로토콜의 이름이 될 수 있다.
  • string version : 도메인의 메이저 버전이다. 다른 버전의 서명은 사용 못한다.
  • uint256 chainId : 현재 활성화된 체인과 맞지 않으면 user-agent가 거절 할 수 있다.
  • address verifyingContract : user-agent가 특정 피싱 방지를 할 수 있다.
  • bytes32 salt : 프로토콜을 위한 임의의 salt 값이다.

위 내용에 대해서 한 개 이상의 값이 들어가 있으면 되고, S에 대해서 재료를 준비했던 것처럼 똑같이 encodeType과 encodeData를 준비하고 해싱해주면 된다.

domainSeparator 같은 경우는 S에 대해 잘못된 서명을 하게 되는 경우 생기는 문제를 일부 해결해주고 있다. 이를테면 잘못된 컨트랙트에, 혹은 잘못된 버전에 사용되는 서명된 데이터를 생성하게 되면 컨트랙트 차원에서 트랜잭션이 실행되는 것을 방지시킬 수 있다.

EIP712 정리

encode(domainSeparator : 𝔹²⁵⁶, message : 𝕊) = "\x19\x01" ‖ domainSeparator ‖ hashStruct(message)

결국 이걸 위해서 이렇게 달려왔다. 위에서 설명한데로 S가 해싱된 값과, domainSeparator, 그리고 “\x19\x01”을 순서대로 나열한 것이 서명을 하기 전에 준비되어야 하는 데이터 규격이다.

구현

구현은 다음과 같이 진행해보려고 한다.

UniSwapV2를 참고해서 ERC20 토큰에 permit 이라는 함수를 구현한다. 클라이언트에서 Typed Structed Data를 서명할 수 있게 만든다. 컨트랙트 상에서 ecrecover 함수가 리턴하는 주소값이 함수를 호출한 당사자의 주소값과 일치하는 로직을 통과해서 최종적으로 일정 양에 대한 토큰의 권리를 상대방에게 이양한다.
순서를 조금 바꿔서 클라이언트에서 데이터를 서명하는 작업을 먼저 진행해보겠다.

클라이언트 서명

React를 이용해서 간단하게 서명을 하는 웹어플리케이션을 만든다. 사용하는 웹브라우저에 메타마스크가 깔려져 있어야한다.

const [from, setFrom] = useState("");
const [to, setTo] = useState("");
const [amount, setAmount] = useState("");
const [signature, setSignature] = useState("");

const clickHandler = async ()=>{
  const {ethereum} = window;
  if (ethereum){
    const account = await ethereum.request({method: 'eth_requestAccounts', params:[]});
    setFrom(account[0]);

    console.log(from, to, amount);

    const typeData = {
      types: {
        EIP712Domain: [
          {name:'name', type:'string'},
          {name:'version', type:'string'},
          {name:'chainId', type:'uint256'},
          {name:'verifyingContract', type:'address'}
        ],
        Permit: [
          {name:'from', type:'address'},
          {name:'to', type:'address'},
          {name:'amount', type:'uint256'},
        ]
      },
      primaryType: "Permit",
      domain: {
        chainId: 1337,
        name: "PermitToken",
        version: "1.0",
        verifyingContract: "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512"
      },
      message: {
        from,
        to,
        amount
      }
    }
    
    try {
      const result = await ethereum.request({
        method: "eth_signTypedData_v4", 
        params: [account[0], JSON.stringify(typeData)]
      })

      console.log(result);
      setSignature(result);

    } catch(err){
      console.log(err);
    }
  }
}

여기서 중요한 것은 typeData가 스키마가 정해져 있다는 것이다.

typeData에 들어가야하는 항목은 다음의 4가지다.

  • types: 서명하는 데이터의 structure를 명시한다.
  • primaryType: 서명하는 데이터의 최상위 타입을 지정한다.
  • domain: EIP712Domain으로 컨트랙트에서 명시하는 eip712domain의 값들과 동일하게 지정한다.
  • message: 서명되는 데이터들의 실질적인 값이다.

JSON 규격과 EIP712에서 type(d)Data의 스키마에 맞춰서 작성하기만 하면 signTypeData 함수에서 에러 없이 결과값을 반환할 것이다.

signTypeData 함수가 반환하는 값은 “0x”로 시작하는 129 bytes 암호화된 데이터다. 문자열로 받게 될 것이다. “0x”를 제외한 나머지 문자열을 지정된 길이만큼 3 부분으로 나눠주면 이더리움에서 사용하는 signature를 구할 수 있다.

// Where signature = result in above example

const r = "0x" + signature.substring(0, 64);
const s = "0x" + signature.substring(64,128);
const v = parseInt(signature.substring(128, 130), 16);

컨트랙트 Permit 함수

컨트랙트는 Solidity 언어를 사용한다.

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

import '@openzeppelin/contracts/token/ERC20/ERC20.sol';

contract EIP712 is ERC20 {
    bytes32 constant PERMIT_TYPEHASH = keccak256(
        "Permit(address from,address to,uint256 amount)"
    );

    bytes32 public DOMAIN_SEPARATOR;

    struct Permit {
        address from;
        address to;
        uint256 amount;
    }

    constructor(string memory name_ , string memory symbol_) 
    ERC20(name_, symbol_){
        DOMAIN_SEPARATOR = keccak256(
            abi.encode(
                keccak256('EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)'),
                keccak256(bytes("PermitToken")),
                keccak256(bytes('1.0')),
                1337,
                address(this)
            )
        );
    }

    function permit(
        address from, 
        address to, 
        uint256 amount,
        uint8 v, bytes32 r, bytes32 s
        ) public{
        bytes32 hashedPotato = hashStruct(Permit(from, to, amount));

        bytes32 digest = keccak256(
            abi.encodePacked(
                '\x19\x01',
                DOMAIN_SEPARATOR,
                hashedPotato
            )
        );

        address recoveredAddress = ecrecover(digest, v, r, s);

        require(recoveredAddress == from, "ERC20: Not Owner");
        _approve(from, to, amount);
    }

    function hashStruct(Permit memory _permit) pure public returns(bytes32 hash){
        return keccak256(abi.encode(
            PERMIT_TYPEHASH,
            _permit.from,
            _permit.to,
            _permit.amount
        ));
    }
}

위에서 말로 힘들게 설명했지만 막상 구현하면 생각보다 별거 없다.

우선 domainSeparator 부터 살펴보자면, 클라이언트에서 구현했던 것처럼 eip712domain에 사용되는 멤버변수와 값들이 일치하게끔 구현을 해주면된다. 필자는 version 에서 “1.0” 과 “1”의 차이로 다른 결과값이 나오는 문제가 있었으니 주의하기 바란다.

hashStruct함수를 살펴보면, 미리 만들어둔 typeHash 값과, 새롭게 인자값으로 받고 있는 Permit의 from, to, amount 값을 abi.enocde 함수를 통해 인코딩한 이후, keccak256 함수로 해싱하고 있는 것을 확인할 수 있다.

만약 여기서 amount 값이 uint256이 아닌 string 형태로 들어온다면 해당 값은 따로 keccak256 함수로 해싱한 이후에 위와 같은 로직으로 다시 인코딩하면 된다. 이를 테면 다음고 같이 구현을 하면 될 것이다.

function hashStruct(Permit memory _permit) pure public returns(bytes32 hash){
    return keccak256(abi.encode(
        PERMIT_TYPEHASH,
        _permit.from,
        _permit.to,
        keccak256(_permit.amount)
    ));
}

테스트 코드

hardhat으로 작성한 테스트코드이다. hashStruct 값이 잘 반환이되는지, approve 함수가 최종적으로 잘 작동했는지 확인한다.

import { expect } from "chai";
import { ethers } from "hardhat";

describe("EIP712", function(){
    async function deployEIP712(){
        const EIP712 = await ethers.getContractFactory("EIP712");
        const eip712 = await EIP712.deploy("PermitToken", "PTN");

        return eip712;
    }

    it("Get hashStruct", async ()=>{
        const eip712 = await deployEIP712();

        const hashedPotato = await eip712.hashStruct({
            from: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
            to: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
            amount: 1000,
        });

        console.log(hashedPotato);
        expect(hashedPotato).exist;
    })

    it("Get Result", async ()=>{
        const eip712 = await deployEIP712();
        console.log(`Deployd at ${eip712.address}`);

        const signature = "d6d88062f4e26c9b64814cd0000868e20b15791f5caff373fc89480d2f987e7b5ab2bdebb0506944946a0f8cbfacbd31dd094d87f7dcd196daa01fdb3ca877d01c"

        const r = "0x" + signature.substring(0, 64);
        const s = "0x" + signature.substring(64,128);
        const v = parseInt(signature.substring(128, 130), 16);
        
        console.log(`r: ${r}`);
        console.log(`s: ${s}`);
        console.log(`v: ${v}`);

        const result = await eip712.permit(
            "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
            "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
            1000,
            v, r, s
        );

        console.log(result);

        const allowance = await eip712.allowance(
            "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
            "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"
        )

        console.log(allowance);

        expect(allowance).to.equal(1000);
    })
})
💡 주의할 점 from, to, amount는 하드코딩이다. from과 to에 들어가는 Public Address는 이용하는 testnetwork에서 만들어주는 주소를 이용하기 바란다. 그리고 여기서 사용되는 모든 주소 값에 자산을 옮기는 것을 금한다. 해당 자산은 증발할 것이다. 또한 배포된 컨트랙트의 주소 또한 하드코딩이 필요하다. 이 때문에 먼저 테스트코드에서 반환하는 컨트랙트의 주소값을 미리 확인하고 클라이언트의 typeData의 컨트랙트 주소값을 수정한 이후에 다시 테스트를 치러야 정확한 결과가 나올 것이다.

적용

  • 오픈마켓 구현에서 이렇게 미리 만들어진 서명데이터를 이용한다면 atomic transaction을 구현할 수 있게 된다.
  • ERC20 Approve 함수를 따로 실행하지 않고도 서명된 데이터와 한 번의 함수 실행으로 컨트랙트의 데이터를 변경할 수 있게 된다.

참고

profile
블록체인 개발 공부 중입니다, 프로그래밍 공부합시다!

0개의 댓글