Calldata 최적화를 위한 짧은 ABI
소개
이 글에서는 낙관적 롤업과 해당 롤업에서의 트랜잭션 비용, 그리고 비용 구조의 차이점으로 인해 이더리움 메인넷과는 다른 것을 최적화해야 하는 이유를 알아봅니다. 또한 이 최적화를 구현하는 방법도 알아봅니다.
전체 공개
저는 Optimism (opens in a new tab) 정직원이므로, 이 글의 예시는 Optimism에서 실행됩니다. 하지만 여기서 설명하는 기법은 다른 롤업에서도 동일하게 작동합니다.
용어
롤업을 논할 때, '레이어 1'(L1)이라는 용어는 운영 중인 이더리움 네트워크인 메인넷을 지칭하는 데 사용됩니다. '레이어 2'(L2)라는 용어는 L1에 보안을 의존하지만 대부분의 처리를 오프체인에서 수행하는 롤업 또는 기타 시스템을 지칭하는 데 사용됩니다.
L2 트랜잭션 비용을 어떻게 더 줄일 수 있을까요?
낙관적 롤업은 모든 과거 트랜잭션 기록을 보존해야 합니다. 그래야 누구나 이를 검토하고 현재 상태가 올바른지 확인할 수 있습니다. 이더리움 메인넷에 데이터를 저장하는 가장 저렴한 방법은 calldata로 작성하는 것입니다. 이 솔루션은 Optimism (opens in a new tab)과 Arbitrum (opens in a new tab) 모두에서 채택되었습니다.
L2 트랜잭션 비용
L2 트랜잭션 비용은 다음 두 가지 요소로 구성됩니다.
- L2 처리 비용(일반적으로 매우 저렴함)
- L1 저장 비용(메인넷 가스 비용과 연동됨)
이 글을 쓰는 시점에서 Optimism의 L2 가스 비용은 0.001 Gwei입니다. 반면에 L1 가스 비용은 약 40gwei입니다. 여기에서 현재 가격을 확인할 수 있습니다 (opens in a new tab).
calldata 1바이트의 비용은 값이 0인 경우 4 가스, 다른 값인 경우 16 가스입니다. EVM에서 가장 비싼 작업 중 하나는 저장 공간에 쓰는 것입니다. L2 저장 공간에 32바이트 워드를 쓰는 최대 비용은 22,100 가스입니다. 현재 이는 22.1 gwei입니다. 따라서 calldata에서 0바이트 하나만 절약할 수 있다면, 저장 공간에 약 200바이트를 쓰고도 여전히 이득을 볼 수 있습니다.
ABI
대부분의 트랜잭션은 외부 소유 계정의 컨트랙트를 액세스한다. 대부분의 계약은 솔리디티로 작성되며 애플리케이션 바이너리 인터페이스(ABI) (opens in a new tab)에 따라 데이터 필드를 해석합니다.
하지만 ABI는 L1을 위해 설계되었습니다. L1에서는 calldata 1바이트 비용이 약 4번의 산술 연산과 같지만, L2에서는 1,000번 이상의 산술 연산보다 비용이 더 많이 듭니다. calldata는 다음과 같이 나뉩니다.
| 섹션 | 길이 | 바이트 | 낭비된 바이트 | 낭비된 가스 | 필요한 바이트 | 필요한 가스 |
|---|---|---|---|---|---|---|
| 함수 선택자 | 4 | 0-3 | 3 | 48 | 1 | 16 |
| 0 | 12 | 4-15 | 12 | 48 | 0 | 0 |
| 목적지 주소 | 20 | 16-35 | 0 | 0 | 20 | 320 |
| 금액 | 32 | 36-67 | 17 | 64 | 15 | 240 |
| 합계 | 68 | 160 | 576 |
설명:
- 함수 선택자: 계약에 256개 미만의 함수가 있으므로, 단일 바이트로 구분할 수 있습니다. 이 바이트들은 일반적으로 0이 아니므로 16 가스가 소모됩니다 (opens in a new tab).
- 0: 이 바이트들은 항상 0인데, 20바이트 주소를 담는 데 32바이트 워드가 필요하지 않기 때문입니다.
0을 담는 바이트는 4 가스가 소모됩니다(옐로 페이퍼 (opens in a new tab), 부록 G, 27페이지,
Gtxdatazero값 참조). - 금액: 이 계약에서
decimals가 18(일반적인 값)이고 우리가 전송할 토큰의 최대량이 1018이라고 가정하면, 최대 금액은 1036이 됩니다. 25615 > 1036이므로 15바이트면 충분합니다.
L1에서 160 가스가 낭비되는 것은 보통 무시할 수 있는 수준입니다. 트랜잭션에는 최소 21,000 가스 (opens in a new tab)가 소모되므로 0.8%가 추가되는 것은 중요하지 않습니다.
하지만 L2에서는 상황이 다릅니다. 트랜잭션 비용의 거의 전부는 L1에 쓰는 데서 발생합니다.
트랜잭션 calldata 외에도 109바이트의 트랜잭션 헤더(목적지 주소, 서명 등)가 있습니다.
따라서 총비용은 109*16+576+160=2480이며, 이 중 약 6.5%를 낭비하고 있습니다.
목적지를 제어할 수 없을 때 비용 절감하기
목적지 계약을 제어할 수 없다고 가정하더라도 여전히 이 솔루션 (opens in a new tab)과 유사한 솔루션을 사용할 수 있습니다. 관련 파일을 살펴보겠습니다.
Token.sol
이것이 목적지 계약입니다 (opens in a new tab).
이것은 표준 ERC-20 계약이며, 한 가지 추가 기능이 있습니다.
이 faucet 함수는 모든 사용자가 사용할 토큰을 얻을 수 있게 합니다.
프로덕션 ERC-20 계약에서는 쓸모없겠지만, 테스트를 용이하게 하기 위해 존재하는 ERC-20의 경우 개발을 더 쉽게 만들어 줍니다.
1 /**2 * @dev 호출자에게 1,000개의 토큰을 제공합니다3 */4 function faucet() external {5 _mint(msg.sender, 1000);6 } // function faucetCalldataInterpreter.sol
이 계약은 트랜잭션이 더 짧은 calldata로 호출하도록 되어 있는 계약입니다 (opens in a new tab). 한 줄씩 살펴보겠습니다.
1//SPDX-License-Identifier: Unlicense2pragma solidity ^0.8.0;345import { OrisUselessToken } from "./Token.sol";호출 방법을 알기 위해 토큰 함수가 필요합니다.
1contract CalldataInterpreter {23 OrisUselessToken public immutable token;프록시 역할을 할 토큰의 주소입니다.
12 /**3 * @dev 토큰 주소를 지정합니다4 * @param tokenAddr_ ERC-20 계약 주소5 */6 constructor(7 address tokenAddr_8 ) {9 token = OrisUselessToken(tokenAddr_);10 } // constructor모두 보기토큰 주소는 우리가 지정해야 하는 유일한 매개변수입니다.
1 function calldataVal(uint startByte, uint length)2 private pure returns (uint) {calldata에서 값을 읽습니다.
1 uint _retVal;\n\n require(length < 0x21,\n \"calldataVal 길이 제한은 32바이트입니다\");\n\n require(length + startByte <= msg.data.length,\n \"calldataVal calldatasize를 초과하여 읽으려고 합니다\");단일 32바이트(256비트) 워드를 메모리에 로드한 다음, 원하는 필드에 속하지 않는 바이트를 제거할 것입니다. 이 알고리즘은 32바이트보다 긴 값에는 작동하지 않으며, 물론 calldata의 끝을 넘어 읽을 수도 없습니다. L1에서는 가스를 절약하기 위해 이 테스트를 건너뛰어야 할 수도 있지만, L2에서는 가스가 매우 저렴하므로 우리가 생각할 수 있는 모든 온전성 검사를 사용할 수 있습니다.
1 assembly {2 _retVal := calldataload(startByte)3 }아래에서 볼 수 있듯이, fallback()에 대한 호출에서 데이터를 복사할 수도 있지만, EVM의 어셈블리 언어인 Yul (opens in a new tab)을 사용하는 것이 더 쉽습니다.
여기서는 CALLDATALOAD opcode (opens in a new tab)를 사용하여 startByte에서 startByte+31 바이트를 스택으로 읽습니다.
일반적으로 Yul에서 opcode의 구문은 <opcode name>(<첫 번째 스택 값, 있는 경우>,<두 번째 스택 값, 있는 경우>...)입니다.
12 _retVal = _retVal >> (256-length*8);가장 중요한 length 바이트만 필드의 일부이므로 오른쪽 시프트 (opens in a new tab)를 사용하여 다른 값을 제거합니다.
이것은 값을 필드의 오른쪽으로 이동시켜 값 자체가 256거듭제곱을 곱한 값이 아닌 값 자체가 되도록 하는 추가적인 이점이 있습니다.
12 return _retVal;3 }456 fallback() external {솔리디티 계약에 대한 호출이 함수 서명과 일치하지 않으면 fallback() 함수 (opens in a new tab)를 호출합니다(있는 경우).
CalldataInterpreter의 경우 다른 external 또는 public 함수가 없기 때문에 모든 호출이 여기에 도달합니다.
1 uint _func;23 _func = calldataVal(0, 1);calldata의 첫 번째 바이트를 읽습니다. 이 바이트는 함수를 알려줍니다. 여기서 함수를 사용할 수 없는 두 가지 이유가 있습니다.
pure또는view인 함수는 상태를 변경하지 않고 가스를 소모하지 않습니다(오프체인에서 호출될 때). 가스 비용을 줄이려는 시도는 의미가 없습니다.msg.sender(opens in a new tab)에 의존하는 함수.msg.sender의 값은 호출자가 아닌CalldataInterpreter의 주소가 됩니다.
안타깝게도 ERC-20 사양 (opens in a new tab)을 살펴보면 transfer라는 함수 하나만 남습니다.
이것은 transfer(transferFrom을 호출할 수 있으므로)와 faucet(토큰을 우리를 호출한 사람에게 다시 전송할 수 있으므로) 두 개의 함수만 남깁니다.
12 // calldata의 정보를 사용하여3 // 토큰의 상태 변경 메서드를 호출합니다45 // faucet6 if (_func == 1) {매개변수가 없는 faucet() 호출입니다.
1 token.faucet();2 token.transfer(msg.sender,3 token.balanceOf(address(this)));4 }token.faucet()을 호출하면 토큰을 얻습니다. 그러나 프록시 계약으로서 우리는 토큰이 필요하지 않습니다.
우리를 호출한 EOA(외부 소유 계정) 또는 계약에는 필요합니다.
그래서 우리는 모든 토큰을 우리를 호출한 사람에게 전송합니다.
1 // transfer(허용량이 있다고 가정)2 if (_func == 2) {토큰을 전송하려면 목적지 주소와 금액이라는 두 개의 매개변수가 필요합니다.
1 token.transferFrom(2 msg.sender,호출자가 소유한 토큰만 전송하도록 허용합니다
1 address(uint160(calldataVal(1, 20))),목적지 주소는 바이트 #1에서 시작합니다(바이트 #0은 함수임). 주소이므로 길이는 20바이트입니다.
1 calldataVal(21, 2)이 특정 계약의 경우, 누구나 전송하고자 하는 최대 토큰 수가 2바이트(65536 미만)에 들어간다고 가정합니다.
1 );2 }전체적으로 전송에는 35바이트의 calldata가 필요합니다.
| 섹션 | 길이 | 바이트 |
|---|---|---|
| 함수 선택자 | 1 | 0 |
| 목적지 주소 | 32 | 1-32 |
| 금액 | 2 | 33-34 |
1 } // fallback23} // contract CalldataInterpretertest.js
이 JavaScript 단위 테스트 (opens in a new tab)는 이 메커니즘을 사용하는 방법(그리고 올바르게 작동하는지 확인하는 방법)을 보여줍니다. 저는 여러분이 chai (opens in a new tab)와 ethers (opens in a new tab)를 이해한다고 가정하고 계약에 특별히 적용되는 부분만 설명하겠습니다.
1const { expect } = require("chai");23describe("CalldataInterpreter", function () {4 it("토큰을 사용할 수 있도록 해야 합니다", async function () {5 const Token = await ethers.getContractFactory("OrisUselessToken")6 const token = await Token.deploy()7 await token.deployed()8 console.log("Token addr:", token.address)910 const Cdi = await ethers.getContractFactory("CalldataInterpreter")11 const cdi = await Cdi.deploy(token.address)12 await cdi.deployed()13 console.log("CalldataInterpreter addr:", cdi.address)1415 const signer = await ethers.getSigner()모두 보기두 계약을 모두 배포하여 시작합니다.
1 // 가지고 놀 토큰 가져오기2 const faucetTx = {ABI를 따르지 않기 때문에 일반적으로 사용하는 고수준 함수(예: token.faucet())를 사용하여 트랜잭션을 생성할 수 없습니다.
대신 트랜잭션을 직접 만들고 전송해야 합니다.
1 to: cdi.address,2 data: "0x01"거래를 위해 제공해야 하는 두 가지 매개변수가 있습니다.
to, 목적지 주소입니다. 이것은 calldata 인터프리터 계약입니다.data, 보낼 calldata입니다. 수도꼭지 호출의 경우 데이터는 단일 바이트인0x01입니다.
12 }3 await (await signer.sendTransaction(faucetTx)).wait()이미 목적지(faucetTx.to)를 지정했고 트랜잭션에 서명이 필요하기 때문에 서명자의 sendTransaction 메서드 (opens in a new tab)를 호출합니다.
1// faucet이 토큰을 올바르게 제공하는지 확인2expect(await token.balanceOf(signer.address)).to.equal(1000)여기서 잔액을 확인합니다.
view 함수에 대한 가스를 절약할 필요가 없으므로 정상적으로 실행합니다.
1// CDI에 허용량 부여(승인은 프록시 처리 불가)2const approveTX = await token.approve(cdi.address, 10000)3await approveTX.wait()4expect(await token.allowance(signer.address, cdi.address)).to.equal(10000)전송을 수행할 수 있도록 calldata 인터프리터에 허용량을 부여합니다.
1// 토큰 전송2const destAddr = "0xf5a6ead936fb47f342bb63e676479bddf26ebe1d"3const transferTx = {4 to: cdi.address,5 data: "0x02" + destAddr.slice(2, 42) + "0100",6}전송 트랜잭션을 생성합니다. 첫 번째 바이트는 "0x02"이고 그 뒤에 목적지 주소, 마지막으로 금액(0x0100, 10진수로 256)이 옵니다.
1 await (await signer.sendTransaction(transferTx)).wait()23 // 256개의 토큰이 더 적은지 확인4 expect (await token.balanceOf(signer.address)).to.equal(1000-256)56 // 그리고 목적지에서 토큰을 받았는지 확인7 expect (await token.balanceOf(destAddr)).to.equal(256)8 }) // it9}) // describe모두 보기목적지 계약을 제어할 때 비용 절감하기
목적지 계약을 제어할 수 있는 경우 calldata 인터프리터를 신뢰하기 때문에 msg.sender 검사를 우회하는 함수를 생성할 수 있습니다.
여기에서 control-contract 브랜치에서 이것이 어떻게 작동하는지에 대한 예시를 볼 수 있습니다 (opens in a new tab).
계약이 외부 트랜잭션에만 응답하는 경우 계약 하나만으로도 충분할 수 있습니다. 그러나 그렇게 하면 구성 가능성이 깨질 수 있습니다. 일반적인 ERC-20 호출에 응답하는 계약과 짧은 호출 데이터가 있는 트랜잭션에 응답하는 다른 계약을 사용하는 것이 훨씬 좋습니다.
Token.sol
이 예제에서는 Token.sol을 수정할 수 있습니다.
이를 통해 프록시만 호출할 수 있는 여러 함수를 가질 수 있습니다.
다음은 새로운 부분입니다.
1 // CalldataInterpreter 주소를 지정할 수 있는 유일한 주소2 address owner;34 // CalldataInterpreter 주소5 address proxy = address(0);ERC-20 계약은 승인된 프록시의 ID를 알아야 합니다. 하지만 아직 값을 모르기 때문에 생성자에서 이 변수를 설정할 수 없습니다. 프록시가 생성자에서 토큰 주소를 기대하기 때문에 이 계약이 먼저 인스턴스화됩니다.
1 /**2 * @dev ERC20 생성자를 호출합니다.3 */4 constructor(5 ) ERC20("Oris useless token-2", "OUT-2") {6 owner = msg.sender;7 }생성자의 주소(owner라고 함)는 프록시를 설정할 수 있는 유일한 주소이기 때문에 여기에 저장됩니다.
1 /**2 * @dev 프록시(CalldataInterpreter)의 주소를 설정합니다.3 * 소유자만 한 번 호출할 수 있습니다4 */5 function setProxy(address _proxy) external {6 require(msg.sender == owner, "소유자만 호출할 수 있습니다");7 require(proxy == address(0), "프록시가 이미 설정되었습니다");89 proxy = _proxy;10 } // function setProxy모두 보기프록시는 보안 검사를 우회할 수 있기 때문에 특권 액세스 권한을 가집니다.
프록시를 신뢰할 수 있도록 owner만 이 함수를 한 번만 호출하도록 허용합니다.
proxy가 실제 값(0이 아님)을 가지면 해당 값은 변경할 수 없으므로 소유자가 악의적으로 변하거나 니모닉이 공개되더라도 우리는 여전히 안전합니다.
1 /**2 * @dev 일부 함수는 프록시에서만 호출할 수 있습니다.3 */4 modifier onlyProxy {이것은 다른 함수의 작동 방식을 수정하는 제어자 함수 (opens in a new tab)입니다.
1 require(msg.sender == proxy);먼저 프록시에서 호출했는지 확인하고 다른 사람이 호출하지 않았는지 확인합니다.
그렇지 않으면 revert합니다.
1 _;2 }그렇다면 수정하는 함수를 실행합니다.
1 /* 프록시가 실제로 계정을 프록시할 수 있도록 하는 함수 */23 function transferProxy(address from, address to, uint256 amount)4 public virtual onlyProxy() returns (bool)5 {6 _transfer(from, to, amount);7 return true;8 }910 function approveProxy(address from, address spender, uint256 amount)11 public virtual onlyProxy() returns (bool)12 {13 _approve(from, spender, amount);14 return true;15 }1617 function transferFromProxy(18 address spender,19 address from,20 address to,21 uint256 amount22 ) public virtual onlyProxy() returns (bool)23 {24 _spendAllowance(from, spender, amount);25 _transfer(from, to, amount);26 return true;27 }모두 보기이 세 가지 작업은 일반적으로 토큰을 전송하거나 허용량을 승인하는 주체로부터 메시지가 직접 와야 합니다. 여기에는 이러한 작업의 프록시 버전이 있습니다.
onlyProxy()에 의해 수정되어 다른 사람이 제어할 수 없습니다.- 일반적으로
msg.sender가 될 주소를 추가 매개변수로 가져옵니다.
CalldataInterpreter.sol
calldata 인터프리터는 프록시된 함수가 msg.sender 매개변수를 수신하고 transfer에 대한 허용량이 필요하지 않다는 점을 제외하고 위의 것과 거의 동일합니다.
1 // transfer (허용량 필요 없음)2 if (_func == 2) {3 token.transferProxy(4 msg.sender,5 address(uint160(calldataVal(1, 20))),6 calldataVal(21, 2)7 );8 }910 // approve11 if (_func == 3) {12 token.approveProxy(13 msg.sender,14 address(uint160(calldataVal(1, 20))),15 calldataVal(21, 2)16 );17 }1819 // transferFrom20 if (_func == 4) {21 token.transferFromProxy(22 msg.sender,23 address(uint160(calldataVal( 1, 20))),24 address(uint160(calldataVal(21, 20))),25 calldataVal(41, 2)26 );27 }모두 보기Test.js
이전 테스트 코드와 이 코드 사이에 몇 가지 차이점이 있습니다.
1const Cdi = await ethers.getContractFactory("CalldataInterpreter")2const cdi = await Cdi.deploy(token.address)3await cdi.deployed()4await token.setProxy(cdi.address)ERC-20 계약이 어떤 프록시를 신뢰할 지 알 수 있도록 해야합니다.
1console.log("CalldataInterpreter addr:", cdi.address)23// 허용량을 확인하기 위해 두 명의 서명자가 필요합니다4const signers = await ethers.getSigners()5const signer = signers[0]6const poorSigner = signers[1]approve() 및 transferFrom()을 확인하려면 두 번째 서명자가 필요합니다.
이 계정은 우리의 토큰을 받지 않기 때문에 poorSigner라고 부릅니다(물론 ETH는 보유해야 합니다).
1// 토큰 전송2const destAddr = "0xf5a6ead936fb47f342bb63e676479bddf26ebe1d"3const transferTx = {4 to: cdi.address,5 data: "0x02" + destAddr.slice(2, 42) + "0100",6}7await (await signer.sendTransaction(transferTx)).wait()ERC-20 계약이 프록시(cdi)를 신뢰하기 때문에 전송을 릴레이하는 데 허용량이 필요하지 않습니다.
1// approval 및 transferFrom2const approveTx = {3 to: cdi.address,4 data: "0x03" + poorSigner.address.slice(2, 42) + "00FF",5}6await (await signer.sendTransaction(approveTx)).wait()78const destAddr2 = "0xE1165C689C0c3e9642cA7606F5287e708d846206"910const transferFromTx = {11 to: cdi.address,12 data: "0x04" + signer.address.slice(2, 42) + destAddr2.slice(2, 42) + "00FF",13}14await (await poorSigner.sendTransaction(transferFromTx)).wait()1516// approve / transferFrom 콤보가 올바르게 수행되었는지 확인17expect(await token.balanceOf(destAddr2)).to.equal(255)모두 보기새로운 두 함수를 테스트해 보세요.
transferFromTx에는 허용량을 제공하는 사람과 받는 사람의 두 가지 주소 매개변수가 필요합니다.
결론
Optimism (opens in a new tab)과 Arbitrum (opens in a new tab) 모두 L1에 기록되는 calldata의 크기를 줄여 트랜잭션 비용을 절감하는 방법을 찾고 있습니다. 그러나 일반적인 솔루션을 찾는 인프라 제공업체로서 우리의 능력은 제한적입니다. dapp 개발자로서 여러분은 애플리케이션별 지식을 가지고 있으므로 일반적인 솔루션보다 훨씬 더 잘 calldata를 최적화할 수 있습니다. 이 글이 여러분의 필요에 맞는 이상적인 해결책을 찾는 데 도움이 되기를 바랍니다.
여기서 제 작업에 대한 자세한 내용을 확인하세요 (opens in a new tab).
페이지 마지막 업데이트됨: 2025년 8월 22일