체인의정석

EIP712 사용하여 오더북 만들기, EIP712 사용법 프론트엔드에서 스마트컨트렉트까지 본문

블록체인/NFT & BRIDGE & OpenSea

EIP712 사용하여 오더북 만들기, EIP712 사용법 프론트엔드에서 스마트컨트렉트까지

체인의정석 2022. 7. 20. 15:47
728x90
반응형

오늘은 그간 삽질했던 EIP712에 대한 구현 내용을 러프하게 한번 정리하고 넘어가려고 한다.

해당 내용은 추후 출간할 책 및 미디엄 글을 통해 더 디테일 하게 설명할 예정이다.

 

https://eips.ethereum.org/EIPS/eip-712

 

EIP-712: Ethereum typed structured data hashing and signing

 

eips.ethereum.org

오픈씨로 대표되는 NFT 거래소와 같이 오더북을 만들고 나서 그걸 스마트컨트렉트에서 검증하고 교환해주는 방식을 요즘 Web2.5라고도 부르는거 같다 뭔가 오프체인이 섞여있어서 이런 용어를 만든거 같긴한데 아무튼 이런곳에 많이 쓰이는데

왜이렇게 소수점 찍는걸 좋아할까? ㅋㅋㅋ

 

아무튼 이러한 오더북을 만들때 쓰이는 서명은 아래 3가지 타입 중 하나가 들어가게 된다.

 

오픈씨의 경우 첫번째 wyvernExchange가 2번째 사인방식이였고,

오더북에 악의적인 데이터를 넣어 하는 공격이 성공하자

3번째 사인방식으로 바꿔서 지금까지 쓰고 있다. 이게 바로 EIP712의 서명 방식이다.

encode(transaction : 𝕋) = RLP_encode(transaction)
encode(message : 𝔹⁸ⁿ) = "\x19Ethereum Signed Message:\n" ‖ len(message) ‖ message where len(message) is the non-zero-padded ascii-decimal encoding of the number of bytes in message.
encode(domainSeparator : 𝔹²⁵⁶, message : 𝕊) = "\x19\x01" ‖ domainSeparator ‖ hashStruct(message) where domainSeparator and hashStruct(message) are defined below.

EIP712 중 최근 프로덕트들은 EIP712를 지원하는 V4 형식의 사인타입을 사용한다.

데이터를 그냥 사인하면 오더의 내용을 모르고 사인하는 상황이 발생하니 Order라는 구조체를 만들어서 거래에 대한 정보를 깔끔하게 만든 후 유저들이 자신이 싸인하는 거래가 어떤 거래인지 한번 체크를 하고 나서 서명할 수 있다는 장점이 있다.

 

이렇게 서명을 하면 signature가 나오는데 해당 signature의 경우 원래 들어간 order 구조체를 입력값으로 넣고 함수를 돌리면 원래 주소가 나온다.

 

먼저 메타마스크에서 서명을 받는 양식의 경우 아래와 같이 원하는 정보를 양식에 맞춰서 넣으면 된다.

정확한 예제는 메타마스크 API에 나와있으니 이걸 참고하면 된다. 서명데이터중 V4를 보면된다.

 

https://docs.metamask.io/guide/signing-data.html#sign-typed-data-v4

 

Signing Data | MetaMask Docs

Signing Data Since MetaMask makes cryptographic keys available to each user, websites can use these signatures for a variety of uses. Here are a few guides related to specific use cases: If you’d like to jump to some working signature examples, you can v

docs.metamask.io

<div>
  <h3>Sign Typed Data V4</h3>
  <button type="button" id="signTypedDataV4Button">sign typed data v4</button>
</div>

javascript

signTypedDataV4Button.addEventListener('click', function (event) {
  event.preventDefault();

  const msgParams = JSON.stringify({
    domain: {
      // Defining the chain aka Rinkeby testnet or Ethereum Main Net
      chainId: 1,
      // Give a user friendly name to the specific contract you are signing for.
      name: 'Ether Mail',
      // If name isn't enough add verifying contract to make sure you are establishing contracts with the proper entity
      verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC',
      // Just let's you know the latest version. Definitely make sure the field name is correct.
      version: '1',
    },

    // Defining the message signing data content.
    message: {
      /*
       - Anything you want. Just a JSON Blob that encodes the data you want to send
       - No required fields
       - This is DApp Specific
       - Be as explicit as possible when building out the message schema.
      */
      contents: 'Hello, Bob!',
      attachedMoneyInEth: 4.2,
      from: {
        name: 'Cow',
        wallets: [
          '0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826',
          '0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF',
        ],
      },
      to: [
        {
          name: 'Bob',
          wallets: [
            '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB',
            '0xB0BdaBea57B0BDABeA57b0bdABEA57b0BDabEa57',
            '0xB0B0b0b0b0b0B000000000000000000000000000',
          ],
        },
      ],
    },
    // Refers to the keys of the *types* object below.
    primaryType: 'Mail',
    types: {
      // TODO: Clarify if EIP712Domain refers to the domain the contract is hosted on
      EIP712Domain: [
        { name: 'name', type: 'string' },
        { name: 'version', type: 'string' },
        { name: 'chainId', type: 'uint256' },
        { name: 'verifyingContract', type: 'address' },
      ],
      // Not an EIP712Domain definition
      Group: [
        { name: 'name', type: 'string' },
        { name: 'members', type: 'Person[]' },
      ],
      // Refer to PrimaryType
      Mail: [
        { name: 'from', type: 'Person' },
        { name: 'to', type: 'Person[]' },
        { name: 'contents', type: 'string' },
      ],
      // Not an EIP712Domain definition
      Person: [
        { name: 'name', type: 'string' },
        { name: 'wallets', type: 'address[]' },
      ],
    },
  });

  var from = web3.eth.accounts[0];

  var params = [from, msgParams];
  var method = 'eth_signTypedData_v4';

  web3.currentProvider.sendAsync(
    {
      method,
      params,
      from,
    },
    function (err, result) {
      if (err) return console.dir(err);
      if (result.error) {
        alert(result.error.message);
      }
      if (result.error) return console.error('ERROR', result);
      console.log('TYPED SIGNED:' + JSON.stringify(result.result));

      const recovered = sigUtil.recoverTypedSignature_v4({
        data: JSON.parse(msgParams),
        sig: result.result,
      });

      if (
        ethUtil.toChecksumAddress(recovered) === ethUtil.toChecksumAddress(from)
      ) {
        alert('Successfully recovered signer as ' + from);
      } else {
        alert(
          'Failed to verify signer when comparing ' + result + ' to ' + from
        );
      }
    }
  );
});

 

 

아무튼 이걸 참고해서 만든건 대충 이런식이다.

테스트를 할때 매번 메타마스크 UI를 만들고 거기서 클릭을 할 수는 없으니 

 

동일 양식으로 메타마스크에서 서명하는 행위를 테스트코드에서 옮겨와서 구현해주어야 한다.

 

아래처럼 하면 된다. 나중에 까먹을 나 자신을 위해 대충 만들어본 예시이다.

    msgParams = {
      domain: {
        // Give a user friendly name to the specific contract you are signing for.
        name: name, // 메타마스크에 표시되는 컨트렉트 이름, 예를들어 0000거래소
        // Just let's you know the latest version. Definitely make sure the field name is correct.
        version: version, // 눈에 보이는 명시하는 버전
        // Defining the chain aka Rinkeby testnet or Ethereum Main Net
        chainId: ethers.BigNumber.from(
          await 0000거래소의 컨트렉트.getChainId()
        ).toNumber(), 최신버전에서는 직접 체인 아이디를 가져올 수 있음
        // If name isn't enough add verifying contract to make sure you are establishing contracts with the proper entity
        verifyingContract: 사인을 하는 컨트렉트의 주소, 0000거래소의 CA
      },

      // Defining the message signing data content.
      message: {
        /*
        - Anything you want. Just a JSON Blob that encodes the data you want to send
        - No required fields
        - This is DApp Specific
        - Be as explicit as possible when building out the message schema.
        */
        ContractAddress: 컨트렉트 CA,
        makerAddress: 구매자 EOA,
        erc721: erc721Token.address,
        tokenId: tokenId,
        price: ethers.BigNumber.from(price).toNumber(),
        .
        .
        .
        등등
      },
      // Refers to the keys of the *types* object below.
      primaryType: "Order",
      types: {
        Order: [
          { name: "ContractAddress", type: "address" },
          { name: "makerAddress", type: "address" },
          { name: "erc721", type: "address" },
          { name: "tokenId", type: "uint256" },
          { name: "price", type: "uint256" },
          .
          .
          .
          등등
        ],
      },
    };

그리고 여기에서 필요한것 3가지는 domain, types, message가 필요하다.

    //ethers 모듈의 함수들
    let signatureA:SignerWithAddress;
    .
    .
    .
    
    
    signatureA = await signerA._signTypedData(
      msgParams.domain,
      msgParams.types,
      msgParams.message
    );

	.
    .
    .
    
    const verifiedAddress = ethers.utils.verifyTypedData(
      msgParams.domain,
      msgParams.types,
      msgParams.message,
      signatureA
    );

    expect(verifiedAddress).to.equal(signatureA.address);

여기서는 맨 아래쪽이라고 할 수 있다.

ethers 모듈에서 해당 작업을 하기 위해서 사용한 함수는 아래와 같다.

https://docs.ethers.io/v5/api/signer/#Signer-signTypedData

 

Signers

Documentation for ethers, a complete, tiny and simple Ethereum library.

docs.ethers.io

// All properties on a domain are optional
const domain = {
    name: 'Ether Mail',
    version: '1',
    chainId: 1,
    verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC'
};

// The named list of all type definitions
const types = {
    Person: [
        { name: 'name', type: 'string' },
        { name: 'wallet', type: 'address' }
    ],
    Mail: [
        { name: 'from', type: 'Person' },
        { name: 'to', type: 'Person' },
        { name: 'contents', type: 'string' }
    ]
};

// The data to sign
const value = {
    from: {
        name: 'Cow',
        wallet: '0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826'
    },
    to: {
        name: 'Bob',
        wallet: '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB'
    },
    contents: 'Hello, Bob!'
};

signature = await signer._signTypedData(domain, types, value);
// '0x463b9c9971d1a144507d2e905f4e98becd159139421a4bb8d3c9c2ed04eb401057dd0698d504fd6ca48829a3c8a7a98c1c961eae617096cb54264bbdd082e13d1c'

이런식으로 signature를 가져오면 되며

https://docs.ethers.io/v5/api/utils/signing-key/#utils-verifyTypedData

 

Signing Key

Documentation for ethers, a complete, tiny and simple Ethereum library.

docs.ethers.io

이걸로 되돌리면 된다.

 

EIP712 형태의 데이터를 만들때는 아래와 같이 만들면 되는데

규약에 맞게 각 요소를 맞춘 domainSeprator를 첫번째 요소로 넣어주어야한다. EIP712 domain에 들어가는 요소들을 해시하면 해당 값이 나오게 된다. 이 코드는 아래 오픈제플린 소스코드에 첨부해 두었다.

 

encode(domainSeparator : 𝔹²⁵⁶, message : 𝕊) = "\x19\x01" ‖ domainSeparator ‖ hashStruct(message) where domainSeparator and hashStruct(message) are defined below.

 

그 다음 각각 요소들을 넣고 역시 해시를 하면 된다. 이 부분이 오픈제플린에서 나와 있지 않는 입력값을 만드는 부분이다. 이렇게 각 요소를 해시한 구조체의 해시를 hash struct라고 하는데 이때 유의해야 될 점은 첫번째 값은 들어가는 구조체의 요소를 모두 명시하고 이를 해시시킨 타입해시 라는 것을 첫번째 요소로 넣여야 된다는 것이다. 이것도 코드로 간단히 예시를 적어두겠다.

이것도 코드로 보자면 아래처럼 나열한 후 keccack256을 한번 해주면 된다.

  bytes32 constant STRUCT_TYPEHASH = keccak256("Struct(address ContractAddress,....,자료형 변수명)");

해시 스트럭트를 만드는 부분은 아래와 같다. 타입해쉬를 맨 위에 쓰고 아래는 각 요소들을 써서 해시를 해주면 된다.

  //make hash struct of the order
  function DoHashStruct(StructA memory structA)
      public
      view
      returns (bytes32)
  {
      return
        keccak256(
          abi.encode(
          ORDER_TYPEHASH,
          structA.ContractAddress,
          ,
          ,
          ,
         )
        );
  }

이런식으로 하면 되고 물론 struct A는 따로 정의해 놔야 한다.

 

이번에 짤때는 오픈씨와 오픈제플린의 코드 스타일을 반반 섞어서 Struct나 constants , enum은 다른 솔리디티 파일로 빼두었다.

그리고 더 EIP 712의 경우 오픈제플린의 eip712를 상속받은 컨트렉트를 하나 만들어서 거기다가 name과 version을 넘겼다.

 

오픈씨는 보니까 10개의 소스코드 넘께 생성자를 넘기던데.. 난 대충 3단계 정도까지 분리해둔거 같다.

오픈제플린의 EIP712 코드가 가장 보기 좋으니 구현때는 이걸 참고하도록 하자

 

다만 이름은 draft이다. 이에 대해 외국 형님들의 반응은...

https://github.com/OpenZeppelin/openzeppelin-contracts/issues/3237

 

EIP-712 still draft? · Issue #3237 · OpenZeppelin/openzeppelin-contracts

Any reasons why EIP-712 is still in draft status? Is it save to use for new projects?

github.com

"문제는 없고 다만 EIP라는게 바뀔 수 있으니까 draft라고 한다."

 

지금 계속해서 시나리오 테스트 중인데 문제는 딱히 나오지 않았고,

 

https://docs.openzeppelin.com/contracts/4.x/releases-stability#drafts

 

New Releases and API Stability - OpenZeppelin Docs

Developing smart contracts is hard, and a conservative approach towards dependencies is sometimes favored. However, it is also very important to stay on top of new releases: these may include bug fixes, or deprecate old patterns in favor of newer and bette

docs.openzeppelin.com

Drafts

Some contracts implement EIPs that are still in Draft status, recognizable by a file name beginning with draft-, such as utils/cryptography/draft-EIP712.sol. Due to their nature as drafts, the details of these contracts may change and we cannot guarantee their stability. Minor releases of OpenZeppelin Contracts may contain breaking changes for the contracts labelled as Drafts, which will be duly announced in the changelog. The EIPs included are used by projects in production and this may make them less likely to change significantly.

EIP가 바뀌는거야.. 뭐 어쩔 수 없지 않나 싶다.

 

아무튼 위에 ethers함수들도 그렇고 이 깃허브 링크도 정말 작정하고 들어가지 않으면 검색해도 잘 안나와서 온갖 구글 후에 간신히 찾아낸 건데

https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/utils/cryptography/draft-EIP712.sol

 

GitHub - OpenZeppelin/openzeppelin-contracts: OpenZeppelin Contracts is a library for secure smart contract development.

OpenZeppelin Contracts is a library for secure smart contract development. - GitHub - OpenZeppelin/openzeppelin-contracts: OpenZeppelin Contracts is a library for secure smart contract development.

github.com

여기 있는 코드를 쓰면된다.

 

여기서 domainSeprator도 알아서 다 만들어준다.

    function _buildDomainSeparator(
        bytes32 typeHash,
        bytes32 nameHash,
        bytes32 versionHash
    ) private view returns (bytes32) {
        return keccak256(abi.encode(typeHash, nameHash, versionHash, block.chainid, address(this)));
    }

한마디로 구조체에 대한 hashStruct만 잘 만들어 준다면

 

양쪽의 서명을 한번 에 받아서 DB에 넣어놨다가 한번에 거래를 성사시켜주는 소위 web2.5형태의 오더북 매칭을 실행 시킬 수 있다.

 

그리고 시나리오 테스트하다가 알게 된 또 하나의 사실

이렇게 될 경우 동일 Order가 올라왔을때 구분이 안되기 때문에

Orderhash를 키 값으로 취소 정보를 저장시키는 구조라서 동일한 해시값이 나오면 안된다.

 

salt의 역할을 할 무언가가 필요하다는 것이다.

EIP712를 보면 string도 가능하기 때문에 그냥 개별 거래마다 고유의 인덱스를 만들어서 넣어주는 작업이 필요하다.

나는 그 값을 UUID로 일단은 잡아서 구현해 보았는데 아래 포스팅에 이어서 정리를 해두었다.

 

https://it-timehacker.tistory.com/318

 

Solidity 0.8.0 버전에서 HashStruct안에 UUID 넣는 법

 일단 해당 게시글은 hashStruct안에 UUID를 넣고 싶은 상황에서 오류가 나서 해결하기 위해 고민한 결과를 담고 있다. 먼저 테스트를 위해서 UUID를 만들어야 했다. 그 부분은 https://it-timehacker.tistory.

it-timehacker.tistory.com

 

글은 이거 하나가 나왔지만 정보도 너무 없고 소스코드나 심지어 호출하는 함수마저 꽁꽁 숨겨져 있어 너무 힘들었다. 하지만 그만큼 한번 해결하고 나니 잘 써먹을 수 있는 기능을 하나 마스터한 느낌이다.

 

찾다보니 ethers로 되어 있는 예제가 하나 더 있어서 첨부한다.

https://issuecloser.com/blog/ethersjs-signing-eip712-typed-structs

 

ethers - EthersJS - Signing EIP712 Typed Structs

Heya! In this post we're going to go a quick look into using EthersJS API for the EIP712 standard. Also, before we dive in, I would like to point out that if your use case is simple as a single message, you likely do not need to complicate it with EIP712.

issuecloser.com

무엇보다 공식 깃허브에 쓰인 예시가 가장 좋다.

https://github.com/ethereum/EIPs/blob/master/EIPS/eip-712.md

 

GitHub - ethereum/EIPs: The Ethereum Improvement Proposal repository

The Ethereum Improvement Proposal repository. Contribute to ethereum/EIPs development by creating an account on GitHub.

github.com

 

728x90
반응형
Comments