💡 본 포스팅에서는
ethers js
를 사용하여 Solidity의 view 메서드를 호출하는 기본적인 방식을 담으려 합니다.
💡 본 시리즈의 모든 내용은 ethers js v5를 기준으로 작성되었습니다.
❗ 작성일(2023.11.05) 기준 6.8.1버전까지 공개된 ethers js v6와 상이한 내용이 있을 수 있습니다!
🙇 잘못된 내용에 대한 지적은 항상 감사드립니다!!
ethers js 공식 문서
Ethereum StackExchange
Human readable Contract ABI 관련 글
이하에서 작성할 Interface
, Contract
등의 클래스를 생성하기 위해서는,
호출할 스마트 컨트랙트에 대한 정보를 담은 ABI(Application Binary Interface) 파일이 필요합니다.
공식 문서에 따르면, 사용 가능한 ABI 의 형식은 JSON
, Full
을 비롯하여 4가지 정도가 있지만, 본 포스팅에서는 개인적으로 많이 사용했던 2가지 유형에 대해서만 다루려 합니다.
함께 작업하는 컨트랙트 개발자가 있거나, 직접 스마트 컨트랙트를 배포하는 경우 사용하기 좋은 방식일 것 같습니다.
이전 포스팅에서 간단히 언급했듯, 스마트 컨트랙트가 배포되면 배포된 컨트랙트 주소와, 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":[]
}
]
개별 함수 및 이벤트의 정의 부분(혹은 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)'
]
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))'
// ]
// ]
이전 포스팅에서 잠시 언급했듯, 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
클래스는 스마트 컨트랙트와의 통신을 통상적인 javascript 객체처럼 사용 가능하도록 한 것으로, 아래와 같이 View 메서드에 바로 접근할 수 있습니다.
const tokenSymbol = await sampleContract.symbol();
const decimals = await sampleContract.decimals();
Contract
클래스는 사용하기 편리하지만, 동일한 인터페이스여도 하나의 컨트랙트만 호출할 수 있어, 개인적으로는 이 2번째 방식을 더 많이 사용하는 편입니다!// 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
를 사용한 데이터 인코딩 ]
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
메서드와 헷갈리지 않도록 유의!정상적으로 디코딩되었다면, 아래와 같이 readble한 결과값이 반환됩니다!
(예시에서는 토큰 컨트랙트의 symbol입니다)
위 예시처럼, 반환값이 단일 String이어도 decoded
결과값이 배열로 반환되고, type을 찍어 보면 object
라고 나오는 경우가 있습니다.
이 때문에, 의도한 반환값을 얻기 위해서 값을 배열에서 꺼내야 하는 경우가 있을 수 있는 것 같습니다!