본문 바로가기
블록체인

[이더리움] Access list

by dbadoy 2023. 4. 12.

현재 이더리움의 트랜잭션 타입은 총 세 가지(Legacy, Dynamic fee, Access list)가 존재한다. Legacy와 Dynamic fee에 대해서는 파악하고 있는데, Access list는 무엇인지 전혀 모르고 있다는 걸 깨달았다. 그도 그럴게 인터넷의 여러 자료들을 봐도 Access list를 사용하는 예제나 코드는 찾아볼 수가 없었고, 자연스레 관심을 가지지 않게 되었던 것 같다.

 

이 글을 작성하게 된 계기는, 이더리움 상하이 하드 포크 업데이트 내용을 보다가 EIP-3651: Worm COINBASE 가 적용된 것을 보고 궁금증이 생겨 EIP 내용을 봤더니 Access list와 관련된 내용이 나와 이해하기가 힘들다고 느꼈다. 글을 작성하고 나면 Access list 트랜잭션이 무엇인지, 앞으로 Access list와 관련된 업데이트가 됐을 때 그 내용을 파악할 수 있게 되었으면 좋겠다.

 

Access list

Access list는 특정 컨트랙트를 호출할 때, 호출자가 접근할 컨트랙트의 주소 및 slot 키값들의 목록을 미리 저장하고 있다. 노드는 스토리지 읽기가 예측 가능할 때 트랜잭션 처리가 더 쉬우며, 데이터베이스에서 데이터를 미리 로드하고 트랜잭션이 수신될 때 병렬로 데이터를 로드할 수 있기 때문에 리소스 사용량이 줄어든다. 리소스 사용량이 줄어든 만큼 Access list를 넘겨준 호출자는 가스비에 대한 혜택을 받게 된다.

 

예측 불가능한 값에 대한 첫 호출은 더욱 비싸고, 한 번 호출된 값은 예측이 가능하기 때문에 훨씬 저렴한 가스비가 책정되어 있다.

여기서 첫 호출을 Cold, 한 번 호출된(예측 가능한) 값을 Warm이라고 표현한다.

계정 엑세스 값의 Cold 가스 비용은 2600, 스테이트의 Cold 엑세스 가스 비용은 2100으로 고정되어 있으며, Warm 가스 비용은 100 가스로 고정되어 있다.

엑세스에 대한 Cold, Warm 비용은 EIP-2929에서 적용된 내용이다. 해당 EIP가 적용되기 전에는 엑세스와 관련된 OPCODE 가스 비용이 낮게 책정되어 왔기 때문에, 2016년 상하이 DoS 공격에 취약한 클라이언트 버그가 수정된 후에도 공격자는 단순히 많은 수의 계정에 액세스하거나 호출하는 트랜잭션을 전송하는 방식으로 DoS 공격을 시도했다. 이 과정에서 엑세스 관련 가스비도 낮다고 판단되어, 현재의 2600, 2100으로 상향되었다. 

 

여기서 문제는, Cold 엑세스 가스비가 상향되며 이전에 배포되었던 일부 컨트랙트가 중지되는 상황이 발생한 것이다. 실제 예시를 찾아보지는 않았지만, 컨트랙트 어딘가에서 고정된 양의 가스에 의존하거나, 블록 가스 한도에 근접한 경우의 트랜잭션 상황에서 정상적인 처리가 안되었다고 한다. 

 

이와 같은 문제를 완화하기 위해 EIP-2930, 즉 Access list가 도입된다.

EIP-2930 트랜잭션(이하 Access list 트랜잭션)은 SLOAD 작업 실행 중에 Cold 스토리지 비용이 할인된 선불로 지불된다는 점을 제외하면 다른 트랜잭션과 동일한 방식으로 수행된다. 위에서 잠깐 Access list를 넘겨준 호출자는 가스비에 대한 혜택을 받는다고 했었는데, 혜택이란 Cold 엑세스에 대해서 200 가스 비용을 할인해준다는 것이다. 즉, 계정 Cold 엑세스는 2600 -> 2400, 스테이트 Cold 엑세스는 2100 -> 1900이 되겠다.

 

가스비 절감에 대한 예시를 한번 보자. (출처)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;

contract Calculator {
    uint public x = 20;
    uint public y = 20;

    function getSum() public view returns (uint256) {
        return x + y;
    }
}

contract Caller {
    Calculator calculator;

    constructor(address _calc) {
        calculator = Calculator(_calc);
    }

    // call the getSum function in the calculator contract
    function callCalculator() public view returns (uint sum) {
        sum = calculator.getSum();
    }
}

 

위 컨트랙트의 Caller를 Legacy 타입으로 1번, Access list 타입으로 1번 호출하여 가스비를 비교해본다. Access list로는 호출하는 컨트랙트의 주소, 슬롯 두 개의 키를 넘겨주겠다.

 

const [user] = await ethers.getSigners();

const data = "0xf4acc7b5"; // methodID `callCalculator()`

const Calculator = await ethers.getContractFactory("Calculator");
const calculator = await Calculator.deploy();
await calculator.deployed();

console.log(`Calc contract deployed to ${calculator.address}`);

const Caller = await ethers.getContractFactory("Caller");
const caller = await Caller.deploy(calculator.address);
await caller.deployed();
  
const accessListTx = {
    from: user.address,
    to: caller.address,
    data: data,
    value: 0,
    type: 1, // type 지정
    accessList: [
      {
        address: calculator.address,
        storageKeys: [
          "0x0000000000000000000000000000000000000000000000000000000000000000",
          "0x0000000000000000000000000000000000000000000000000000000000000001",
        ],
      },
    ],
};

const legacyTx = {
    from: user.address,
    to: caller.address,
    data: data,
    value: 0,	
};

// 31234
await user.sendTransaction(legacyTx);

// 30934
await user.sendTransaction(accessListTx);

300 가스가 절감되었다. 왜 600 가스가 줄어든 것이 아니라 300 가스가 줄었을까? Caller는 Access list로 Calculator의 주소와 slot 0번과 1번을 미리 넘겼으니 세 개에 대한 할인 비용인 600가스가 줄어야 하는데...

 

600가스가 할인된 것은 맞지만 Caller에서 Calculator 주소, slot 0번, 1번에 대한 Warm 엑세스를 하는 비용이 소모된 것이다. 즉, 

1 * (2600 - 2400)                   +  2 * (2100 - 1900)                      -  3 * 100                        = 300
계정 Cold 엑세스 할인된 가스 + 스테이트 Cold 엑세스 할인된 가스 - Warm 엑세스 3번
 
이와 같다.

 

 

Access list 트랜잭션을 호출할 때, 실제로 사용되지 않는 값을 넣어서 전송한다면 무의미한 엑세스로 인한 가스비가 소모되기 때문에 꼭 사용되는 값인 경우, 정적인 값인 경우에만 넣어 전송해야 한다.

또, 단일 컨트랙트를 호출하는 경우에는 추가적인 엑세스 비용이 발생하지 않기 때문에 Access list를 사용할 이유가 없고, 다른 컨트랙트를 호출하는 과정에서 발생하는 엑세스 비용에 대한 절감 효과를 기대할 수 있다.

Access list는 하나의 컨트랙트에만 엑세스하는 트랜잭션에 대해서는 어떠한 이점도 없다.

 

여기까지 Access list가 도입된 이유, 적용 효과에 대해서 알아보았다. 

지금이라면 EIP-3651: Warm COINBASE에 대해서 이해할 수 있을까?

 

go-ethereum의 stateDB에는 Prepare라는 메서드가 존재한다. (링크)

이 메서드는 스테이트 전환 전에 반드시 실행되어야 하는 메서드인데, 내부 내용을 보면 Access list에 특정 주소들을 미리 저장시키고 있다. 가스비를 절감시키기 위해 DoS 공격과 무관한 값들은 미리 넣어주는 것인데, 기존에는 precompile된 컨트랙트 주소값, from과 to를 Access list에 등록해주었다.

이전에는 사전 Access list 추가 과정에 COINBASE가 포함되어 있지 않아 Cold 상태였고, COINBASE는 블록 보상과 거래 수수료를 받기 때문에 항상 로드[1]되어야 하는 값이어서 항상 로드에 대해 Cold 비용이 나갔었다. EIP-3651은 COINBASE를 Access list에 미리 추가하여 엑세스 비용을 줄이자는 제안이다.

 

현재 go-ethereum에 해당 내용이 머지되었는데 위 내용으로 유추할 수 있듯이 변경 내용도 단순하다. (링크)

stateDB Prepare 과정에서 클라이언트가 상하이 포크된 상태면 Access list에 COINBASE를 추가해준다.

 

 

[1] flashbot과 같은 MEV 빌더들은 COINBASE를 자주 호출하는데(한 블록당 최소 1번, 많으면 2~3번) Cold 엑세스라 너무 비싸니 Access list에 넣어주자라는 것... 자세한 건 여기