[Ethers Js 겉핥기] #2. View 함수의 호출

toto9602·2023년 11월 5일
0

Ethers js

목록 보기
3/5

💡 본 포스팅에서는 ethers js를 사용하여 Solidity의 view 메서드를 호출하는 기본적인 방식을 담으려 합니다.

💡 본 시리즈의 모든 내용은 ethers js v5를 기준으로 작성되었습니다.

❗ 작성일(2023.11.05) 기준 6.8.1버전까지 공개된 ethers js v6와 상이한 내용이 있을 수 있습니다!

🙇 잘못된 내용에 대한 지적은 항상 감사드립니다!!

참고 자료

ethers js 공식 문서
Ethereum StackExchange
Human readable Contract ABI 관련 글

들어가기 전에 : ABI 파일

이하에서 작성할 Interface, Contract 등의 클래스를 생성하기 위해서는,
호출할 스마트 컨트랙트에 대한 정보를 담은 ABI(Application Binary Interface) 파일이 필요합니다.

공식 문서에 따르면, 사용 가능한 ABI 의 형식은 JSON, Full 을 비롯하여 4가지 정도가 있지만, 본 포스팅에서는 개인적으로 많이 사용했던 2가지 유형에 대해서만 다루려 합니다.

JSON

함께 작업하는 컨트랙트 개발자가 있거나, 직접 스마트 컨트랙트를 배포하는 경우 사용하기 좋은 방식일 것 같습니다.

이전 포스팅에서 간단히 언급했듯, 스마트 컨트랙트가 배포되면 배포된 컨트랙트 주소와, ABI 파일이 반환되는데, 이때 Solidity Compiler에서 기본적으로 반환하는 ABI 파일 유형이 이 JSON ABI format입니다.

[ JSON ABI format 예시 ]

[
  	{
      "type":"function",
     "name":"transferFrom",
      "constant":false,
      "payable":false,
      "inputs":[
        {
          "type":"address",
          "name":"from"
        },
        {
          "type":"address",
          "name":"to"
        },
        {
          "type":"uint256",
          "name":"amount"
        }
      ],
      "outputs":[]
    }
]  
  • 예시에는 한 함수의 정보만을 담았지만, 실제로는 스마트 컨트랙트에서 사용하는 구조체(struct) 정보 등, 해당 스마트 컨트랙트 호출을 위한 모든 정보를 포함합니다.
  • Compiler에 의한 반환되는 파일을 그대로 사용하는 만큼 가장 확실하고 안정적입니다.
  • 다만, 파일 분량이 커지는 경우 파일 변경시마다 변경되는 지점을 확인하기가 다소 어렵고, 특정 스마트 컨트랙트에서 호출하는 함수가 적어도 전체 ABI 파일을 관리해야 하는 경우 다소 불편한 측면도 있었습니다.

Full (Human-readable ABI)

개별 함수 및 이벤트의 정의 부분(혹은 signature)을 String의 배열 형태로 적는 방식입니다.
보다 자세한 설명은 공식 문서에서 소개된 해당 글을 참고하시면 좋을 것 같습니다!

[ Full Format 예시 ]

 [
   'function transferFrom(address from, address to, uint256 amount)',
   'function mint(uint256 amount) payable',
   'function balanceOf(address owner) view returns (uint256)',
   'event Transfer(address indexed from, address indexed to, uint256 amount)',
   'error AccountLocked(address owner, uint256 balance)',
   'function addUser(tuple(string name, address addr) user) returns (uint256 id)',
   'function addUsers(tuple(string name, address addr)[] user) returns (uint256[] id)',
   'function getUser(uint256 id) view returns (tuple(string name, address addr) user)'
 ]
  • 함수의 이름, 매개변수, 공개 범위 및 유형, 반환값의 형태 등을 string 형태로 작성합니다.
  • 매개변수 및 반환값이 구조체(struct) 타입인 경우, 정상 호출을 위해 해당 구조체를 tuple과 Solidity 기본 데이터타입으로 풀어서 작성해 주어야 합니다.
  • 사용할 함수만 작성해 주면 되기에 간단하지만, signature 전체가 하나의 문자열이기에, 오타 등의 문제를 확인하기 어려운 점은 다소 불편한 것 같습니다.

cf. 서로 다른 유형으로의 Formatting

ABI 파일로 ethers의 Interface 클래스를 생성하면, format 메서드를 통해 다른 format의 ABI 파일을 사용할 수 있습니다.

import * as ABI from './abi.json';

// 1. JSON 형태, 혹은 String의 배열 형태의 ABI로 Interface 클래스를 생성
const iface = ethers.utils.Interface(ABI);

// 2. ethers 라이브러리에서 FormatTypes 객체를 조회
const FormatTypes = ethers.utils.FormatTypes;

// 3. 각각의 유형으로 formatting (이하 예시는 ethers js Docs 참고)
iface.format(FormatTypes.json)
// '[{"type":"constructor","payable":false,"inputs":[{"type":"string","name":"symbol"},{"type":"string","name":"name"}]},{"type":"function","name":"transferFrom","constant":false,"payable":false,"inputs":[{"type":"address","name":"from"},{"type":"address","name":"to"},{"type":"uint256","name":"amount"}],"outputs":[]},{"type":"function","name":"mint","constant":false,"stateMutability":"payable","payable":true,"inputs":[{"type":"uint256","name":"amount"}],"outputs":[]},{"type":"function","name":"balanceOf","constant":true,"stateMutability":"view","payable":false,"inputs":[{"type":"address","name":"owner"}],"outputs":[{"type":"uint256"}]},{"type":"event","anonymous":false,"name":"Transfer","inputs":[{"type":"address","name":"from","indexed":true},{"type":"address","name":"to","indexed":true},{"type":"uint256","name":"amount"}]},{"type":"error","name":"AccountLocked","inputs":[{"type":"address","name":"owner"},{"type":"uint256","name":"balance"}]},{"type":"function","name":"addUser","constant":false,"payable":false,"inputs":[{"type":"tuple","name":"user","components":[{"type":"string","name":"name"},{"type":"address","name":"addr"}]}],"outputs":[{"type":"uint256","name":"id"}]},{"type":"function","name":"addUsers","constant":false,"payable":false,"inputs":[{"type":"tuple[]","name":"user","components":[{"type":"string","name":"name"},{"type":"address","name":"addr"}]}],"outputs":[{"type":"uint256[]","name":"id"}]},{"type":"function","name":"getUser","constant":true,"stateMutability":"view","payable":false,"inputs":[{"type":"uint256","name":"id"}],"outputs":[{"type":"tuple","name":"user","components":[{"type":"string","name":"name"},{"type":"address","name":"addr"}]}]}]'

iface.format(FormatTypes.full)
// [
//   'constructor(string symbol, string name)',
//   'function transferFrom(address from, address to, uint256 amount)',
//   'function mint(uint256 amount) payable',
//   'function balanceOf(address owner) view returns (uint256)',
//   'event Transfer(address indexed from, address indexed to, uint256 amount)',
//   'error AccountLocked(address owner, uint256 balance)',
//   'function addUser(tuple(string name, address addr) user) returns (uint256 id)',
//   'function addUsers(tuple(string name, address addr)[] user) returns (uint256[] id)',
//   'function getUser(uint256 id) view returns (tuple(string name, address addr) user)'
// ]

iface.format(FormatTypes.minimal)
// [
//   'constructor(string,string)',
//   'function transferFrom(address,address,uint256)',
//   'function mint(uint256) payable',
//   'function balanceOf(address) view returns (uint256)',
//   'event Transfer(address indexed,address indexed,uint256)',
//   'error AccountLocked(address,uint256)',
//   'function addUser(tuple(string,address)) returns (uint256)',
//   'function addUsers(tuple(string,address)[]) returns (uint256[])',
//   'function getUser(uint256) view returns (tuple(string,address))'
// ]
// ]

View 함수 호출하기

Contract 클래스를 사용한 호출

Contract 클래스 생성

이전 포스팅에서 잠시 언급했듯, Contract 클래스 초기화 시에 컨트랙트 주소, ABI, provider 클래스 를 매개 변수로 사용합니다.

[ Contract 클래스 초기화 예시 ](from. Ethreum StackExchange )]

// human-readable ABI 형식
const ABI = [
   "function name() public view returns (string)",
   "function symbol() public view returns (string)",
   "function decimals() public view returns (uint8)",
   "function totalSupply() public view returns (uint256)",
   "function approve(address _spender, uint256 _value) public returns (bool success)"
];

const ETHEREUM_RPC_URL = `https://eth.llamarpc.com`

// 컨트랙트와의 Read-Only 상호작용을 위한 provider 클래스
const provider = new ethers.providers.JsonRpcProvider(ETHEREUM_RPC_URL);

const sampleContract = new ethers.Contract(`${CONTRACT_ADDRESS}`, ABI, provider);

Contract 클래스를 통한 호출

Contract 클래스는 스마트 컨트랙트와의 통신을 통상적인 javascript 객체처럼 사용 가능하도록 한 것으로, 아래와 같이 View 메서드에 바로 접근할 수 있습니다.

const tokenSymbol = await sampleContract.symbol();

const decimals = await sampleContract.decimals();

Interface와 Provider를 사용한 호출

  • Contract 클래스는 사용하기 편리하지만, 동일한 인터페이스여도 하나의 컨트랙트만 호출할 수 있어, 개인적으로는 이 2번째 방식을 더 많이 사용하는 편입니다!

Interface와 Provider 클래스의 생성

// JSON ABI 형식 사용
import * as ABI from "./abi.json"

const ETHEREUM_RPC_URL = `https://eth.llamarpc.com`

// provider 클래스 초기화
const provider = new ethers.providers.JsonRpcProvider(ETHEREUM_RPC_URL);

// Interface 클래스 초기화
const iface = new ethers.utils.Interface(ABI);

Interface와 Provider 클래스를 활용한 호출

해당 방식의 호출은, 크게 아래와 같은 순서를 거칩니다.

  1. Interface를 사용하여 호출에 사용할 데이터를 인코딩
  2. 인코딩한 데이터를 Provider에 실어 컨트랙트 호출
  3. 호출 반환값을 human-readable한 형태로 디코딩

[ 1. Interface 를 사용한 데이터 인코딩 ]

const encodedData = iface.encodeFunctionData("symbol", []);
  • Interface 클래스의 encodeFunctionData 메서드를 사용하여 인코딩을 진행합니다.
  • 첫 번째 인자로 호출할 함수의 이름, 두 번째 인자로 호출할 함수에 담을 매개변수를 배열로 입력합니다.
  • 위 예시에는 symbol 메서드를 호출하고, 해당 함수는 매개변수가 없으므로 2번째 인자로 빈 배열을 넣어줍니다.

여기까지 진행 후, encodedData를 console에 찍어보면, 아래와 같이 인코딩된 문자열 데이터를 확인할 수 있습니다.

[ 2. 인코딩한 데이터를 Provider에 실어 컨트랙트 호출 ]

const resultData = await provider.call(
  {
    to:`${CONTRACT_ADDR},
	data: encodedData
  }
);
  • Provider 클래스의 call 메서드를 활용하여 컨트랙트를 호출합니다.
  • to 필드에 호출할 컨트랙트의 주소를, data 필드에 인코딩한 데이터를 넣어줍니다.

여기까지 진행 후, 컨트랙트가 정상적으로 호출되었다면 아래와 같이 반환값이 인코딩된 데이터 형태로 넘어옵니다!

아직 이 데이터는 human-readable한 형태가 아니므로, 디코딩하는 과정을 거쳐야 합니다!

[ 3. 호출 반환값을 디코딩 ]

const decoded = iface.decodeFunctionResult("symbol", resultData);
  • Interface 클래스의 decodeFunctionResult 메서드를 이용합니다.
    • decodeFunctionData 메서드와 헷갈리지 않도록 유의!
  • 첫 번째 인자로 역시 메서드의 이름을, 두 번째 인자로 2. 에서 반환된 데이터를 넣어줍니다.

정상적으로 디코딩되었다면, 아래와 같이 readble한 결과값이 반환됩니다!
(예시에서는 토큰 컨트랙트의 symbol입니다)

cf. 반환값 타입

위 예시처럼, 반환값이 단일 String이어도 decoded 결과값이 배열로 반환되고, type을 찍어 보면 object라고 나오는 경우가 있습니다.

이 때문에, 의도한 반환값을 얻기 위해서 값을 배열에서 꺼내야 하는 경우가 있을 수 있는 것 같습니다!

profile
주니어 백엔드 개발자입니다! 조용한 시간에 읽고 쓰는 것을 좋아합니다 :)

0개의 댓글