본문 바로가기
블록체인

[이더리움] Multicall contract

by dbadoy 2023. 4. 8.

오늘은 간단하면서도 재밌는 컨셉의 컨트랙트를 발견하여 그와 관련된 글을 적어 보려한다. 

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의 크기가 이더리움 프로토콜에 고정되어 있지는 않기 때문에, 노드마다 다르게 설정될 수 있다. 따라서 우리는 각자 사용하는 노드에 대한 실용적인 최대값을 구한 뒤, 그에 맞게 사용해야 한다.