오늘은 간단하면서도 재밌는 컨셉의 컨트랙트를 발견하여 그와 관련된 글을 적어 보려한다.
Multicall
(https://github.com/mds1/multicall)
Multicall은 한 번의 호출만으로 여러 컨트랙트 조회값을 리턴 받을 수 있는 컨트랙트다.
여러 컨트랙트를 각각 조회하면 되는데, 왜 굳이 다른 컨트랙트를 거쳐서 결과값을 얻어야 할까?
기본적으로 한 번에 많은 요청을 하는 것이 요청을 여러 번 하는 것보다 오버헤드가 적고, Infura와 같은 노드 서비스를 운영한다면 rpc 요청 수에 제한이 있거나, 과금이 발생하기 때문에 다소 번거로움이 있더라도 요청 수를 줄이려는 노력은 합리적으로 보인다.
컨트랙트의 역할에서 유추할 수 있지만, Multicall 컨트랙트 자체가 복잡한 기능을 하지 않기 때문에 코드를 보면 무엇을 하겠다는 건지 확 와닿는다.
struct Call {
address target;
bytes callData;
}
function aggregate(Call[] calldata calls) public returns (uint256 blockNumber, bytes[] memory returnData) {
blockNumber = block.number;
returnData = new bytes[](calls.length);
for (uint256 i = 0; i < calls.length; i++) {
(bool success, bytes memory ret) = calls[i].target.call(calls[i].callData);
require(success);
returnData[i] = ret;
}
}
호출할 컨트랙트들의 주소와 콜데이터를 Call 구조체로 만든 뒤, Multicall.aggregate 파라미터에 Call 배열 구조체를 넘겨주면 넣어준 순서대로 결과값이 리턴된다.
예를 들어, 토큰1, 토큰2, 토큰3에 대한 잔액 조회를 해야 한다면, 기존에는 각 토큰 컨트랙트에 총 3번의 요청을 해야 하지만 Multicall을 사용하면 한 번의 요청으로 원하는 세 가지 값을 가져올 수 있다.
Hardhat을 이용하여 간단한 예제를 작성해보자.
Multicall 컨트랙트를 배포하고, Hardhat 기본 컨트랙트인 Greeter 세 개를 각기 다른 값으로 생성한 뒤, Multicall을 이용해 greet() 리턴값 세 개를 한 번에 가져온다.
//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.4;
contract Greeter {
string private greeting;
constructor(string memory _greeting) {
greeting = _greeting;
}
function greet() public view returns (string memory) {
return greeting;
}
}
Multicall과 Greeter 세 개를 배포하자.
Multicall: https://github.com/mds1/multicall/blob/main/src/Multicall.sol 사용
const Multicall = await ethers.getContractFactory("Multicall");
const Greeter = await ethers.getContractFactory("Greeter");
const multicall = await Multicall.deploy();
const greeter1 = await Greeter.deploy("Greeter1");
await greeter1.deployed();
const greeter2 = await Greeter.deploy("Greeter2");
await greeter2.deployed();
const greeter3 = await Greeter.deploy("Greeter3");
await greeter3.deployed();
Multicall로 greet()을 호출하기 위해서는 메서드 아이디를 구하고 콜데이터에 넣어주어야 한다.
greet() 메서드 아이디값은 0xcfae3217
const res = await multicall.callStatic.aggregate([
{ target: greeter1.address, callData: '0xcfae3217' },
{ target: greeter2.address, callData: '0xcfae3217' },
{ target: greeter3.address, callData: '0xcfae3217' },
]);
for (const data of res.returnData) {
console.log(hex2string(data));
}
/*
Greeter1
Greeter2
Greeter3
*/
파라미터로 넘긴 요청의 순서에 맞게 리턴값이 넘어온 것을 확인할 수 있다.
시작한김에 이를 응용하여 메인넷에 배포된 여러 ERC-20의 0xdAC17F958D2ee523a2206206994597C13D831ec7 계정 잔액도 확인 해보자. callData는 balanceOf 메서드 아이디와 파라미터로 넘길 address인 0xdAC17F958D2ee523a2206206994597C13D831ec7을 이용해 생성해서 넣어주자.
메인넷에 이미 배포되어 있는 Multicall을 이용한다. 주소는 0xeefba1e63905ef1d7acba5a8513c70307c1ce441 이다.
const provider = new ethers.providers.JsonRpcProvider(
'https://eth.llamarpc.com'
);
const wallet = new ethers.Wallet(
'000000000000000000000000000000000000000000000000000000000000000a', provider
);
const multicall = await ethers.getContractAt('Multicall', '0xeefba1e63905ef1d7acba5a8513c70307c1ce441', wallet);
const res = await multicall.callStatic.aggregate([
// USDT
{ target: '0xdAC17F958D2ee523a2206206994597C13D831ec7', callData: '0x70a08231000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7' },
// BNB
{ target: '0xB8c77482e45F1F44dE1745F52C74426C631bDD52', callData: '0x70a08231000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7' },
// USD
{ target: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', callData: '0x70a08231000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7' },
]);
for (const data of res.returnData) {
console.log(parseInt(data, 16));
}
/*
68832978434
0
30951998563
*/
같은 인터페이스의 여러 컨트랙트에 대한 호출에는 적극적으로 사용을 고려해볼 것 같으나, 여러 인터페이스 컨트랙트들에 대한 호출을 한다면 파라미터로 넘긴 순서를 저장하고, 받은 리턴값을 각자 핸들링해주어야 하기 때문에 번거로워 보여 고민이 된다.
이쯤에서 한 번의 요청에 최대 몇 개의 balanceOf 요청을 보낼 수 있는지 궁금해지니, 그것까지만 확인하고 글을 마치겠다.
geth기준 RPC 요청의 최대 ContentLength 값이 5 MiB이고, multicall 호출 자체의 데이터는 372 byte, balanceOf 요청 하나 추가할 때 마다 384 byte가 늘어난다.
5242880 = 372 + (384 * n)
n = 13652 - 1(jsonrpc 2.0 오버헤드)
ContentLength가 5 MiB 로 설정되어 있으면 우리는 한 번의 컨트랙트 콜로 13,651번의 ERC-20 balanceOf 요청에 대한 결과값을 얻을 수 있다. 따로 운영하고 있는 노드가 없어 퍼블릭하게 공개된 엔드포인트에 시도해보았을 때는, 2500개의 요청까지 성공하는 것을 보았다. ContentLength의 크기가 이더리움 프로토콜에 고정되어 있지는 않기 때문에, 노드마다 다르게 설정될 수 있다. 따라서 우리는 각자 사용하는 노드에 대한 실용적인 최대값을 구한 뒤, 그에 맞게 사용해야 한다.
'블록체인' 카테고리의 다른 글
[이더리움] chainID, networkID (0) | 2023.04.27 |
---|---|
[이더리움] Access list (0) | 2023.04.12 |
[이더리움 코어] Connection (0) | 2023.03.21 |
이더스캔 분석 2 - 트랜잭션 Overview 페이지 (0) | 2023.03.05 |
이더스캔 분석 1 - 블록 페이지 (1) | 2023.02.26 |