Skip to main content

Contract Error Handling

먼저 Solidity 안에서 에러를 처리할 수 있는 방식들에 대해 알아보자. Solidity에서 예외를 발생시키는 방식은 크게 Assert, Require, Revert가 있다.

세 가지 방식 모두 현재 호출 및 모든 하위 호출에서 일어났던 상태 변경 사항들을 취소하고 호출자에게 오류 플래그를 지정한다.

만약 하위 호출에서 예외가 발생하면, try/catch 명령문 안에서 발견되지 않는 한 자동으로 "버블업" (예외가 다시 발생)된다. 이러한 규칙에 대한 예외는 call, delegatecall, staticcall 과 같은 low-level 함수들이나 send 함수를 사용하여 호출 했을 때 뿐이다. 이 경우에는 하위 호출에서 예외가 발생하더라도 예외가 상위 호출로 전달되는 대신 첫번 째 return 값으로 false가 전달된다.

Assert

Assert는 컨트랙트의 내부 오류를 테스트하고 불변성을 확인하는 데에만 사용해야 한다. 즉 외부의 입력에 따라 어떤 경우에는 Assert 오류가 발생하고 어떤 경우에는 오류가 발생하지 않는 일이 생기면 안 된다. 이런 일이 발생한다는 것은 컨트랙트에 수정해야 할 버그가 존재한다는 뜻이다.

또 한 가지 유의해야할 점은, assert 함수를 통해 생성되는 오류는 Solidity 버전에 따라 차이가 있다는 것이다.

0.8 버전 이전

0.4.22 ~ 0.7.x 버전에서는 assert 함수를 사용했을 때 사용되는 opcode는 INVALID 이다.

INVALID opcode에 대한 정보를 살펴보자.

스택 매개변수가 0,0인 REVERT (Byzantium 포크 이후)와 동일 하지만 현재 컨텍스트에 제공된 모든 가스가 소비된다는 점만 다릅니다.

즉 Solidity 0.8 버전 이전에는 assert 함수를 통해 오류가 생성되더라도 트랜잭션의 가스가 환불되지 않는다. 또 REVERT(0,0) 와 동일하기 때문에 컨트랙트 외부에서는 어떤 이유로 해당 오류가 발생했는지 유추할 수 있는 데이터가 존재하지 않는다.

0.8 버전 이후

0.8 버전부터 assert 함수를 사용했을 때 사용되는 opcode가 REVERT 로 변경된다.

REVERT opcode에 대한 정보를 살펴보자.

현재 컨텍스트 실행을 중지하고 상태 변경을 되돌리고(상태 변경 opcode 목록은 STATICCALL 참조) 사용하지 않은 가스를 호출자에게 반환합니다.

INVALID opcode와 크게 다른 지점은 사용하지 않은 가스를 호출자에게 반환한다는 점이다.

호출 컨텍스트의 반환 데이터는 이 컨텍스트의 지정된 메모리 청크로 설정됩니다.

스택 입력
offset: 메모리의 바이트 오프셋(바이트). 호출 컨텍스트의 반환 데이터입니다.
size: 복사할 바이트 크기(반환 데이터의 크기).

또 위의 설명처럼 반환 데이터를 지정할 수 있기 때문에, 기존 INVALID opcode에서와 달리 컨트랙트 내부에서 어떤 이유로 오류가 발생했는지에 대한 데이터를 제공할 수 있다.

그렇다면 0.8 버전 이후에 assert 함수를 사용했을 때 오류에 대한 정보는 어떤 규칙으로 제공되는지를 살펴보자.

assert 함수는 Panic(uint256) 데이터 유형의 오류를 생성한다. 즉 함수 식별자를 지정하는 방식과 동일하게 "Panic(uint256)"을 keccak256으로 해싱한 데이터의 4바이트 값과 uint256 인자 값을 통해 오류의 정보를 제공한다.

Solidity 공식 문서를 살펴보면, Panic(uint256) 오류는 컨트랙트 작성자가 assert 함수를 통해 임의로 발생시키는 경우 외에도 미리 지정된 특정 상황들에서 컴파일러에 의해 동일한 오류가 생성될 수 있다고 설명하고 있다.

0x00: 일반 컴파일러 삽입 패닉에 사용됩니다.

0x01: assert(false)로 평가되는 인수로 호출하는 경우.

0x11: 산술 연산 결과 .unchecked { ... } 블록 외부에서 언더플로 또는 오버플로가 발생하는 경우.

0x12: 0으로 나누거나 modulo하는 경우 (e.g. 5 / 0 or 23 % 0).

0x21: 너무 크거나 음수인 값을 열거형으로 변환하는 경우.

0x22: 잘못 인코딩된 스토리지 바이트 배열에 액세스하는 경우.

0x31: 빈 배열에 .pop()을 호출하는 경우.

0x32: 배열 및 배열 슬라이스, bytesN의 범위를 벗어난 인덱스 혹은 음수 인덱스에 액세스하는 경우 (i.e. x[i] where i >= x.length or i < 0).

0x41: 너무 많은 메모리를 할당하거나 너무 큰 배열을 생성한 경우.

0x51: 유효하지 않은 내부 함수 호출.

즉, 언더플로 또는 오버플로가 발생했을 때는 Panic(uint256) 의 인자값으로 '0x11'이 사용되며 빈 배열에 .pop() 을 호출한 경우에는 인자값으로 '0x31'이 사용되는 식이다. 만약 작성자가 직접 assert 함수 식을 사용한 경우에는 '0x01'이 인자값으로 사용된다.

Assert Handling

그렇다면 위에서 살펴본 정보를 토대로 컨트랙트를 사용하는 클라이언트 입장에서 Assert 에러를 효율적으로 헨들링할 수 있는 함수를 만들어보자.

0.8 이후 버전을 기준으로 우리는 Assert 에러가 Panic(uint256) 데이터 유형으로 인코딩되어 REVERT opcode에 의해 반환된다는 것을 알고 있다. 어셈블리 코드를 통해 EVM 안에서 해당 동작 과정을 자세히 살펴볼 수 있다.

  function assertRevertOpcode() external pure {
// 0x4e487b71
bytes4 panicId = bytes4(keccak256(bytes("Panic(uint256)")));

assembly {
mstore(0x40, panicId)
mstore(add(0x40, 4), 0x1)
revert(0x40, 36)
}
}

먼저 Panic(uint256) 을 해싱하여 식별 데이터 값을 생성한다. 그리고 해당 데이터를 메모리에 저장하고 (위의 경우 '0x40' 포인터), 그 메모리에 인자값 데이터를 추가한다 (위의 경우 '0x1'로 이 경우 작성자가 assert 함수식을 사용하여 false로 판정된 경우와 동일함). 그리고 REVERT opcode를 사용하여 반환 데이터가 담긴 메모리 위치와 반환 데이터의 크기를 입력한다.

이제 컨트랙트 내부에서 에러 데이터가 어떤 방식으로 반환되는지를 알았으니 외부에서도 역으로 해당 데이터를 디코딩하여 식별할 수 있을 것이다.

// method id of 'Panic(uint256)'
const PANIC_CODE_PREFIX = "0x4e487b71";

가장 먼저 Panic 오류를 식별하기 위한 시그니처 값을 정의한다.

const panicErrorCodeToReason = (errorCode: number): string | undefined => {
switch (errorCode) {
case 0x1:
return "Assertion error";
case 0x11:
return "Arithmetic operation underflowed or overflowed outside of an unchecked block";
case 0x12:
return "Division or modulo division by zero";
case 0x21:
return "Tried to convert a value into an enum, but the value was too big or negative";
case 0x22:
return "Incorrectly encoded storage byte array";
case 0x31:
return ".pop() was called on an empty array";
case 0x32:
return "Array accessed at an out-of-bounds or negative index";
case 0x41:
return "Too much memory was allocated, or an array was created that is too large";
case 0x51:
return "Called a zero-initialized variable of internal function type";
}
};

그리고 Panic의 인자값에 따라 사람이 이해할 수 있는 자세한 오류 정보로 바꿔주는 함수를 작성한다.

const decodeAssertErrorData = (
data: string
): { panicCode: string; panicDescription: string } => {
if (!data.startsWith(PANIC_CODE_PREFIX))
return {
panicCode: "0x",
panicDescription: "not panic error",
};

const encodedReason = data.slice(PANIC_CODE_PREFIX.length);

let decodePanicCode: number;
let panicCode: string;
let panicDescription: string;

try {
decodePanicCode = defaultAbiCoder
.decode(["uint256"], `0x${encodedReason}`)[0]
.toNumber();

panicCode = `0x${decodePanicCode.toString(16)}`;
} catch (e: any) {
return {
panicCode: "0x",
panicDescription: "unknown panic",
};
}

panicDescription = panicErrorCodeToReason(decodePanicCode) ?? "unknown panic";

return { panicCode, panicDescription };
};

이제 반환된 에러 데이터를 바탕으로 사람이 식별할 수 있게 디코딩해주는 함수를 작성해보자. 이 함수의 경우 에러 데이터가 Panic(uint256) 유형의 오류라는 것을 가정하고 있기 때문에, 가장 먼저 반환된 에러 데이터가 PANIC_CODE_PREFIX 로 시작하지 않으면 식별할 수 없는 "not panic error"로 return 한다.

PANIC_CODE_PREFIX 로 시작한다면, 인자값을 추출하기 위해 에러 데이터를 PANIC_CODE_PREFIX 이후로 slice한다. slice한 인자 데이터를 ethers의 defaultAbiCoder.decode 유틸 함수를 통해 디코딩하고, 디코딩된 데이터로 panicErrorCodeToReason 함수를 통해 자세한 오류 정보를 추출한다.

그럼 작성한 함수를 통해 Assert 관련 에러를 발생시키는 컨트랙트를 호출하여 테스트해보자. 앞서 언급했듯이 Assert 에러는 Solidity 버전에 따라 다른 opcode를 사용한다는 것을 먼저 유념해야한다.

contracts/AssertBefore.sol
pragma solidity ^0.7.5;

contract AssertBefore {
function assert0x1() external pure {
assert(false);
}

function assert0x11() external pure {
uint256 a = 5;
uint256 b = 10;

a - b;
}

function assert0x12() external pure {
uint256 a = 5;
uint256 b;

a / b;
}

enum Enum {
A,
B
}

function assert0x21() external pure {
int256 a = -1;
Enum(a);
}

uint256[] public array;

function assert0x31() external {
array.pop();
}

function assert0x32() external view {
array[5];
}

function assertRevertOpcode() external pure {
// 0x4e487b71
bytes4 panicId = bytes4(keccak256(bytes("Panic(uint256)")));

assembly {
mstore(0x40, panicId)
mstore(add(0x40, 4), 0x1)
revert(0x40, 36)
}
}

function assertInvalidOpcode() external pure {
assembly {
invalid()
}
}
}

테스트를 위해 컴파일러에 의해 오류가 발생되는 상황을 재현한 함수들과 직접 assert 함수를 통해 오류를 발생시키는 함수가 존재하는 컨트랙트를 0.8 이전 버전으로 작성하였다.

describe("Assert < 0.8", () => {
it("Assert Before 0x1", async () => {
try {
await assertBefore.assert0x1();

console.log("success");
} catch (error: any) {
console.log("assert 0x1 error data:", decodeAssertErrorData(error.data));
}
});

it("Assert Before Revert Opcode", async () => {
try {
await assertBefore.assertRevertOpcode();

console.log("success");
} catch (error: any) {
console.log(
"assert revert opcode error data:",
decodeAssertErrorData(error.data)
);
}
});

it("Assert Before Invalid Opcode", async () => {
try {
await assertBefore.assertInvalidOpcode();

console.log("success");
} catch (error: any) {
console.log(
"assert invalid opcode error data:",
decodeAssertErrorData(error.data)
);
}
});

it("Assert Before 0x11", async () => {
try {
const result = await assertBefore.assert0x11();

console.log("success", result);
} catch (error: any) {
console.log("assert 0x11 error data:", decodeAssertErrorData(error.data));
}
});

it("Assert Before 0x12", async () => {
try {
await assertBefore.assert0x12();

console.log("success");
} catch (error: any) {
console.log("assert 0x12 error data:", decodeAssertErrorData(error.data));
}
});

it("Assert Before 0x21", async () => {
try {
await assertBefore.assert0x21();

console.log("success");
} catch (error: any) {
console.log("assert 0x21 error data:", decodeAssertErrorData(error.data));
}
});

it("Assert Before 0x31", async () => {
try {
await assertBefore.assert0x31();

console.log("success");
} catch (error: any) {
console.log("assert 0x31 error data:", decodeAssertErrorData(error.data));
}
});

it("Assert Before 0x32", async () => {
try {
await assertBefore.assert0x32();

console.log("success");
} catch (error: any) {
console.log("assert 0x32 error data:", decodeAssertErrorData(error.data));
}
});
});

0.8 버전 이전의 컨트랙트를 호출하는 위의 테스트 코드를 실행했을 때 아래와 같은 결과가 나왔다.

Assert < 0.8
assert 0x1 error data: { panicCode: '0x', panicDescription: 'not panic error' }
✔ Assert Before 0x1

assert revert opcode error data: { panicCode: '0x1', panicDescription: 'Assertion error' }
✔ Assert Before Revert Opcode

assert invalid opcode error data: { panicCode: '0x', panicDescription: 'not panic error' }
✔ Assert Before Invalid Opcode

success BigNumber { value: "115792089237316195423570985008687907853269984665640564039457584007913129639931" }
✔ Assert Before 0x11

assert 0x12 error data: { panicCode: '0x', panicDescription: 'not panic error' }
✔ Assert Before 0x12

assert 0x21 error data: { panicCode: '0x', panicDescription: 'not panic error' }
✔ Assert Before 0x21

assert 0x31 error data: { panicCode: '0x', panicDescription: 'not panic error' }
✔ Assert Before 0x31

assert 0x32 error data: { panicCode: '0x', panicDescription: 'not panic error' }
✔ Assert Before 0x32

0.8 버전 이전에는 INVALID opcode가 사용되고, 해당 opcode는 REVERT(0,0) 와 가스 환불 지점을 제외하면 동일하기 때문에 대부분의 케이스들이 위에서 작성한 디코딩 함수의 첫번 째 if 문에 걸리는 것을 볼 수 있다 (에러 데이터가 그냥 "0x"이므로). 다만 두번 째 테스트 케이스에서는 정상적으로 디코딩이 되었는데, 해당 케이스에서는 REVERT opcode를 사용하는 assertRevertOpcode() 함수를 호출했기 때문이다.

또 한 가지 특이한 지점은 "0x11" (오버플로 및 언더플로 발생) 테스트 케이스의 경우 에러가 나지 않고 정상적으로 함수가 동작했다는 것이다. 그 이유는 오버플로 및 언더플로가 발생했을 때 Solidity에서 오류를 발생시키는 것은 0.8 버전 이후부터이기 때문이다. 따라서 < 0.8 버전인 해당 컨트랙트에서는 오류가 발생하지 않고 언더플로가 발생된 값이 그대로 리턴된 것이다.

그럼 0.8 버전 이후의 컨트랙트를 테스트 해보자. 위와 동일한 코드의 컨트랙트를 버전만 바꿔서 테스트 코드를 실행했을 때 결과는 아래와 같다.

Assert >= 0.8
assert 0x1 error data: { panicCode: '0x1', panicDescription: 'Assertion error' }
✔ Assert 0x1

assert revert opcode error data: { panicCode: '0x1', panicDescription: 'Assertion error' }
✔ Assert Revert Opcode

assert invalid opcode error data: { panicCode: '0x', panicDescription: 'not panic error' }
✔ Assert Invalid Opcode

assert 0x11 error data: {
panicCode: '0x11',
panicDescription: 'Arithmetic operation underflowed or overflowed outside of an unchecked block'}
✔ Assert 0x11

assert 0x12 error data: {
panicCode: '0x12',
panicDescription: 'Division or modulo division by zero'}
✔ Assert 0x12

assert 0x21 error data: {
panicCode: '0x21',
panicDescription: 'Tried to convert a value into an enum, but the value was too big or negative'}
✔ Assert 0x21

assert 0x31 error data: {
panicCode: '0x31',
panicDescription: '.pop() was called on an empty array'}
✔ Assert 0x31

assert 0x32 error data: {
panicCode: '0x32',
panicDescription: 'Array accessed at an out-of-bounds or negative index'}
✔ Assert 0x32

테스트 결과를 살펴보면, 고의적으로 INVALID opcode를 사용한 assertInvalidOpcode() 함수를 제외하고는 모두 정상적으로 에러 데이터가 디코딩된 것을 볼 수 있다.

특히 0.8 이전 컨트랙트에서는 오류가 발생되지 않았던 "0x11" 코드의 경우에도 정상적으로 오류가 발생된 것을 볼 수 있다. 만약 해당 산술 연산 코드를 unchecked{} 블록 안에서 수행했다면 0.8 이전 컨트랙트의 경우처럼 오류가 발생하지 않고 언더플로가 발생한 값이 리턴됐을 것이다.

여담이지만, 오버플로 및 언더플로를 0.8 버전 이후부터는 기존 SafeMath와 같은 라이브러리를 사용하여 직접 체크해주지 않아도 Solidity에서 기본적으로 체크해준다는 것은 분명 용이한 지점이지만 그 말은 곧 오버플로 및 언더플로가 발생할 가능성이 없는 산술 연산(대표적으로 반복문에서 사용되는 변수에 대한 덧셈 및 뺄셈 연산)에서도 해당 오류를 체크하는 오버헤드가 발생할 수 있다는 것을 의미한다. 때문에 상황에 따라 적절하게 unchecked{} 블록을 사용하는 것이 불필요한 오버헤드를 줄일 수 있는 방법이다.

Revert & Require

위에서 살펴본 assert 함수, 즉 Panic(uint256) 데이터 유형으로 반환되지 않는 에러는 Error(string) 데이터 유형으로 반환되거나 에러 데이터 없이 REVERT(0,0) 로 반환된다.

솔리디티 공식 문서에는 아래 상황들에서 Error(string) 데이터 유형의 REVERT 혹은 데이터 없는 REVERT 가 생성된다고 설명하고 있다.

1. Calling require(x) where x evaluates to false.
: require 호출식이 false로 평가되는 경우

2. If you use revert() or revert("description").
: revert() 혹은 revert(에러 메세지)가 호출되는 경우

3. If you perform an external function call targeting a contract that contains no code.
: 코드가 존재하지 않는 컨트랙트로 함수를 호출하는 경우

4. If your contract receives Ether via a public function without payable modifier (including the constructor and the fallback function).
: payable 수정자가 없는 공개 함수가 Ether를 수신하는 경우

5. If your contract receives Ether via a public getter function.
: getter 공개 함수가 Ether를 수신하는 경우

require 함수는 호출식 안의 조건이 false로 평가되는 경우, revert 함수는 호출 그 자체로 예외를 발생시킨다고 볼 수 있다. 그리고 assert 함수와는 다르게 계약 작성자가 필요에 따라 에러에 대한 메세지를 전달할 수 있다.

물론 revertrequire 함수 모두 REVERT opcode를 사용하고 있기 때문에 해당 함수들을 통해 예외를 발생시켰을 때 사용되지 않은 가스는 환불된다.

정리하자면, 입력된 에러 메세지가 존재하지 않을 때 두 함수는 REVERT(0,0) opcode와 동일하며 에러 메세지가 포함된 경우에는 Error(string) 데이터 유형으로 인코딩 되어 REVERT(offset, size) opcode를 통해 반환되는 것이다.

Revert & Require Handling

Assert 에러와 마찬가지로, 외부에서 컨트랙트 함수를 호출할 때 Revert 및 Require 에러를 헨들링할 수 있는 함수를 만들어보자.

위에서 살펴본 것처럼, revertrequire 함수에 에러 메세지가 포함되어있다면 해당 메세지는 Error(string) 함수와 동일한 데이터 유형으로 인코딩되어 반환된다.

먼저 어셈블리 코드를 통해 EVM 안에서의 동작 과정을 살펴보면 아래와 같다.

   function revertByYul() external {
// 0x08c379a0
bytes4 errorSelector = bytes4(keccak256(bytes("Error(string)")));

assembly {
mstore(0x40, errorSelector)
mstore(add(0x40, 4), 32) // data offset
mstore(add(0x40, 36), 14) // string length
mstore(add(0x40, 68), "revert message")
revert(0x40, 100)
}
}

이번에는 Panic(uint256) 대신에 Error(string) 을 keccak256으로 해싱한 값의 4바이츠 식별 데이터 값을 생성한 후에 메모리에 저장한다.

Panic 오류의 인자(uint256)가 정적 유형인 것과 다르게 Error 오류의 인자(string)는 동적 유형으로 구성되어있다. 따라서 솔리디티에서 동적 유형의 데이터를 인코딩하는 방식을 참고하여 정해진 방식에 따라 데이터를 반환해주어야 한다.

solidity abi-spec use of dynamic types 해당 문서를 참고하여 동적 유형의 인자가 포함된 함수의 인코딩 방식을 간단하게 살펴보자. 정적 유형 인자의 경우, 함수 식별값으로 사용되는 4바이츠 시그니처 값 이후에 매개변수의 순서에 따라 인자 데이터가 바로 담기면 되지만 동적 유형 인자의 경우 인자 블록의 시작 부분(함수 시그니처 값 이후)을 기준으로 해당 파라미터의 인자 데이터가 시작되는 위치(offset)를 바이츠 단위로 측정하여 먼저 담는다.

solidity layout in memory 문서를 참고했을 때, 솔리디티의 메모리 배열 요소는 항상 32바이츠의 배수를 차지한다. 따라서 Error(string)의 4바이츠 시그니처값 이후 인자 블록을 기준으로 string 타입의 인자 데이터는 offset 데이터가 담긴 이후 시작되기 때문에 32바이츠가 된다.

동적 유형 인자 데이터의 시작 위치를 알리는 데이터 이후에는 해당 인자 데이터의 바이츠 길이를 담으면 된다. 위 예시 코드에서는 'revert message'의 길이인 14를 입력해주었다. offset과 length 데이터를 담은 이후에 오류와 함께 전달하려는 string 데이터를 입력하면 된다.

이제 솔리디티 내부에서 Error(string) 유형의 에러 데이터가 인코딩 되는 방식을 알았으니 외부에서 해당 데이터를 디코딩하여 식별하는 방법을 알아보자.

    // method id of 'Error(string)'
const ERROR_STRING_PREFIX = "0x08c379a0";

먼저 Error(string)을 식별하기 위한 4바이츠의 시그니처 값이 필요하다.

    const decodeErrorData = (data: string) => {
if (data === "0x") {
return {kind: "Empty"};
} else if (data.startsWith(ERROR_STRING_PREFIX)) {
const encodedReason = data.slice(ERROR_STRING_PREFIX.length);

const reason = defaultAbiCoder.decode(["string"], `0x${encodedReason}`)[0];

return {
kind: "Error",
reason,
};
}
}

반환되는 에러 데이터를 디코딩하여 외부에서 식별할 수 있게 하는 함수를 작성해보자. 위의 함수에서는 에러 데이터가 존재하지 않으면 (REVERT(0,0) 로 반환된 경우) "Empty" 종류로 반환되며, 위에서 정의했던 ERROR_STRING_PREFIX 로 시작되는 경우 string 타입의 인자값을 디코딩하여 함께 반환해준다.

Assert와 마찬가지로 디코딩 함수가 제대로 동작하는지 Revert 및 Require 에러를 발생시키는 테스트 컨트랙트를 작성하고 호출해보자.

contracts/Revert.sol
pragma solidity ^0.8.17;

contract Revert {
function revertWithoutMessage() external {
revert();
}

function revertWithMessage() external {
revert("revert message");
}

function requireWithoutMessage() external {
require(false);
}

function requireWithMessage() external {
require(false, "require message");
}

function revertByYul() external {
// 0x08c379a0
bytes4 errorSelector = bytes4(keccak256(bytes("Error(string)")));

assembly {
mstore(0x40, errorSelector)
mstore(add(0x40, 4), 32) // data offset
mstore(add(0x40, 36), 14) // string length
mstore(add(0x40, 68), "revert message")
revert(0x40, 100)
}
}
}

위의 예시 컨트랙트에서는 revertrequire 를 사용해서 각각 에러 메시지가 없는 경우와 존재하는 경우로 오류를 발생시키는 함수를 작성하였고, 마지막으로 어셈블리 코드를 통해 revert 오류를 메시지와 함께 발생시키는 함수를 작성하였다.

describe("Revert Test", () => {
let revert: Contract;

before(async () => {
const Revert = await ethers.getContractFactory("Revert");
revert = await Revert.deploy();
await revert.deployed();
});

describe("Error", () => {
it("Revert Without Message", async () => {
try {
await revert.revertWithoutMessage();

console.log("success");
} catch (error: any) {
console.log("revert without message:", decodeErrorData(error.data));
}
});

it("Revert With Message", async () => {
try {
await revert.revertWithMessage();

console.log("success");
} catch (error: any) {
console.log("revert with message:", decodeErrorData(error.data));
}
});

it("Require Without Message", async () => {
try {
await revert.requireWithoutMessage();

console.log("success");
} catch (error: any) {
console.log("require without message:", decodeErrorData(error.data));
}
});

it("Require With Message", async () => {
try {
await revert.requireWithMessage();

console.log("success");
} catch (error: any) {
console.log("require with message:", decodeErrorData(error.data));
}
});

it("Revert By Yul", async () => {
try {
await revert.revertByYul();

console.log("success");
} catch (error: any) {
console.log("revert by yul:", decodeErrorData(error.data));
}
});
});
});

위의 테스트 코드를 실행했을 때 아래와 같은 결과가 나왔다.

Revert Test
Error
revert without message: { kind: 'Empty' }
✔ Revert Without Message (46ms)
revert with message: { kind: 'Error', reason: 'revert message' }
✔ Revert With Message (45ms)
require without message: { kind: 'Empty' }
✔ Require Without Message (39ms)
require with message: { kind: 'Error', reason: 'require message' }
✔ Require With Message (48ms)
revert by yul: { kind: 'Error', reason: 'revert message' }
✔ Revert By Yul

테스트 결과를 살펴보았을 때, 컨트랙트에 입력된 에러 메세지 그대로 잘 디코딩되어 나오는 것을 확인할 수 있다.

Custom Error

솔리디티 0.8.4 버전부터는 사용자가 직접 에러 타입을 지정하여 기존 string 타입 뿐만 아니라 다양한 타입의 인자를 전달하는 커스텀 에러를 사용할 수 있다.

Custom Errors in Solidity 해당 문서를 참고하여 커스텀 에러에 대한 자세한 내용을 살펴보자.

커스텀 에러의 장점은 기존 revertrequire 에서 문자열로 고정적으로만 전달할 수 밖에 없던 에러 정보를 다양한 타입으로 상황에 따라 동적으로 전달 가능하면서도 보다 가스 효율적인 방식을 사용한다는 것이다.

커스텀 에러를 사용하는 방식은 사용자가 필요로 하는 error 문을 정의하는 것에서 시작한다.

// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.4;

error Unauthorized();

contract VendingMachine {
address payable owner = payable(msg.sender);

function withdraw() public {
if (msg.sender != owner)
revert Unauthorized();

owner.transfer(address(this).balance);
}
// ...
}

위의 예시 코드를 보면, Unauthorized() 라는 error 문을 정의하고, 해당 에러를 revert 를 통해 사용하는 것을 볼 수 있다. error 구문은 event 구문과 유사하며, 아직까지 require 함수를 통해서는 사용할 수 없고 오직 revert 를 통해서만 사용할 수 있다.

// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.4;

/// Insufficient balance for transfer. Needed `required` but only
/// `available` available.
/// @param available balance available.
/// @param required requested amount to transfer.
error InsufficientBalance(uint256 available, uint256 required);

contract TestToken {
mapping(address => uint) balance;
function transfer(address to, uint256 amount) public {
if (amount > balance[msg.sender])
// Error call using named parameters. Equivalent to
// revert InsufficientBalance(balance[msg.sender], amount);
revert InsufficientBalance({
available: balance[msg.sender],
required: amount
});
balance[msg.sender] -= amount;
balance[to] += amount;
}
// ...
}

커스텀 에러의 장점으로 다양한 타입의 정보를 상황에 따라 동적으로 전달할 수 있다고 한 것처럼, error 구문에는 원하는 타입의 파라미터를 설정하고 상황에 따라 인자를 넣어 에러 정보를 전달할 수 있다.

그렇다면 커스텀 에러를 사용하는 것이 기존의 방식을 사용하는 것보다 어떤 면에서 가스 효율적인지 문서에 언급된 관련 예시 코드를 살펴보자.

// revert Unauthorized();
let free_mem_ptr := mload(64)
mstore(free_mem_ptr, 0x82b4290000000000000000000000000000000000000000000000000000000000)
revert(free_mem_ptr, 4)

// revert("Unauthorized");
let free_mem_ptr := mload(64)
mstore(free_mem_ptr, 0x08c379a000000000000000000000000000000000000000000000000000000000)
mstore(add(free_mem_ptr, 4), 32)
mstore(add(free_mem_ptr, 36), 12)
mstore(add(free_mem_ptr, 68), "Unauthorized")
revert(free_mem_ptr, 100)

첫번 째 예시는 error Unauthorized() 로 정의된 커스텀 에러를 사용하여 예외를 발생시키는 경우를 어셈블리 코드로 풀어낸 것이고, 두번 째 예시는 기존의 revert 함수를 사용해서 "Unauthorized" 메세지를 전달하여 예외를 발생시키는 경우에 대한 어셈블리 코드이다.

error Unauthorized() 에 대한 ABI 인코딩 방식은 Unauthorized() 함수의 인코딩 방식과 동일하다. 따라서 커스텀 에러의 경우 Unauthorized() 함수의 4바이츠 시그니처 값만을 필요로 하지만, 기존 방식을 사용했을 때는 앞서 revert 에 대한 인코딩 방식을 살펴본 것처럼 Error(string) 함수를 인코딩 하는 과정을 거쳐야 한다.

결과적으로 "Unauthorized" 라는 동일한 정보를 전달하고 있지만 커스텀 에러 방식을 사용하는 것이 비용면에서 효율적인 것을 알 수 있다. 특히 런타임 환경에서는 revert의 조건이 충족된 경우에만 두 방식의 차이점이 적용되겠지만 컨트랙트 배포 비용에 있어서는 기본적으로 비용의 차이가 발생한다.

Custom Error Handling

그렇다면 이제 커스텀 에러를 외부에서 헨들링할 수 있는 함수를 만들어보자.

커스텀 에러는 미리 정의된 error 문에 대해 함수를 인코딩하는 방식과 동일하게 인코딩하여 정보를 전달한다.

예를 들어 error CustomErrorWithParameter(uint256 param) 에 대해 param 의 값이 1인 예외를 발생시키는 과정은 아래와 같다.

    function revertCustomErrorWithParameterByYul() external {
bytes4 errorSelector = CustomErrorWithParameter.selector;

assembly {
mstore(0x40, errorSelector)
mstore(add(0x40, 4), 1) // param uint256 value 1 padded to 32 bytes
revert(0x40, 36)
}
}

먼저 CustomErrorWithParameter(uint256 param) 함수를 식별할 수 있는 4바이츠 시그니처 값을 먼저 담고, param 은 정적 유형의 인자이기 때문에 바로 해당 인자의 값인 1을 담아주면 된다.

그럼 외부에서 커스텀 에러를 통해 반환된 데이터를 디코딩하여 식별하는 과정을 살펴보자.

const errorIface = new ethers.utils.Interface([
"error CustomError()",
"error CustomErrorWithParameter(uint256 param)",
]);

먼저 컨트랙트에 정의된 error 구문들을 외부에서 식별할 수 있도록 해당 인터페이스를 생성한다.

const decodeErrorData = (data: string) => {
const sig = data.slice(0, 10);
const error = errorIface.getError(sig);

return {
kind: "Custom",
id: sig,
name: error.name,
data: errorIface.decodeErrorResult(error, data),
};
};

그리고 에러 데이터에서 먼저 함수 시그니처 값을 slice() 하여 에러 인터페이스에서 해당 시그니처 값의 에러를 찾은 뒤에 decodeErrorResult() 함수를 활용하여 디코딩해준다.

그럼 디코딩 함수가 잘 동작하는지 테스트 컨트랙트를 작성하고 호출해보자.

contracts/Revert.sol
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.17;

contract Revert {
error CustomError();
error CustomErrorWithParameter(uint256 param);

function revertCustomError() external {
revert CustomError();
}

function revertCustomErrorWithParameter() external {
revert CustomErrorWithParameter(1);
}

function revertCustomErrorByYul() external {
bytes4 errorSelector = CustomError.selector;

assembly {
mstore(0x40, errorSelector)
revert(0x40, 0x04)
}
}

function revertCustomErrorWithParameterByYul() external {
bytes4 errorSelector = CustomErrorWithParameter.selector;

assembly {
mstore(0x40, errorSelector)
mstore(add(0x40, 4), 1) // param uint256 value 1 padded to 32 bytes
revert(0x40, 36)
}
}
}

위의 예시 컨트랙트에서는 파라미터가 없는 경우와 존재하는 경우의 error 를 정의한 후에 해당 에러들을 발생시키는 함수를 각각 작성하였다.

describe("Revert Test", () => {
let revert: Contract;

before(async () => {
const Revert = await ethers.getContractFactory("Revert");
revert = await Revert.deploy();
await revert.deployed();
});

describe("Custom Error", () => {
it("Revert Custom Error", async () => {
try {
await revert.revertCustomError();

console.log("success");
} catch (error: any) {
console.log("revert custom error:", decodeErrorData(error.data));
}
});

it("Revert Custom Error With Parameter", async () => {
try {
await revert.revertCustomErrorWithParameter();

console.log("success");
} catch (error: any) {
console.log(
"revert custom error with parameter:",
decodeErrorData(error.data)
);
}
});

it("Revert Custom Error By Yul", async () => {
try {
await revert.revertCustomErrorByYul();

console.log("success");
} catch (error: any) {
console.log("revert custom error by yul:", decodeErrorData(error.data));
}
});

it("Revert Custom Error With Parameter By Yul", async () => {
try {
await revert.revertCustomErrorWithParameterByYul();

console.log("success");
} catch (error: any) {
console.log("revert custom error by yul:", decodeErrorData(error.data));
}
});
});
});

위의 테스트 코드를 실행했을 때 아래와 같은 결과가 나왔다.

    Custom Error
revert custom error: { kind: 'Custom', id: '0x09caebf3', name: 'CustomError', data: [] }
✔ Revert Custom Error
revert custom error with parameter: {
kind: 'Custom',
id: '0x241d671b',
name: 'CustomErrorWithParameter',
data: [ BigNumber { value: "1" }, param: BigNumber { value: "1" } ]
}
✔ Revert Custom Error With Parameter
revert custom error by yul: { kind: 'Custom', id: '0x09caebf3', name: 'CustomError', data: [] }
✔ Revert Custom Error By Yul
revert custom error by yul: {
kind: 'Custom',
id: '0x241d671b',
name: 'CustomErrorWithParameter',
data: [ BigNumber { value: "1" }, param: BigNumber { value: "1" } ]
}

결과를 살펴보았을 때, 발생시킨 커스텀 에러의 이름과 데이터가 작성된 컨트랙트의 코드와 동일하게 디코딩되어 나오는 것을 확인할 수 있다.


여기까지 살펴봤을 때, 커스텀 에러에 대한 헨들링은 assert, revert, require 에 대한 헨들링과 비교했을 때 다른 점 한 가지를 발견할 수 있다.

기본적으로 assertPanic(uint256) 유형, revertrequireError(string) 의 유형으로 인코딩 되어 반환된다. 때문에 해당 에러들의 인터페이스는 달라지는 경우 없이 고정되어있다. A 컨트랙트에서든 B 컨트랙트에서는 revert("message") 로 예외를 발생시켰다면 정해진 Error(string) 유형에 따라 인코딩되어 반환된다.

하지만 커스텀 에러는 컨트랙트 작성자가 정의한 error 유형에 따라 데이터가 인코딩된다. 따라서 정의된 인터페이스 정보를 갖고 있지 않다면 커스텀 에러로 반환된 에러 데이터를 외부에서 디코딩할 수 없다. 외부에서 컨트랙트와 상호작용하는 방식은 기본적으로 ABI를 통해 이루어지기 때문에 우리는 ABI로 제공되지 않은 컨트랙트의 함수 및 데이터를 식별할 수 없다. (여태까지 작성된 디코딩 함수들의 기본적인 흐름을 떠올려보자. 우리는 외부에서 Panic(uint256), Error(string), CustomError() 와 같은 인터페이스를 미리 정의해두고, 컨트랙트 내부와 동일한 방식으로 인코딩된 데이터를 컨트랙트에서 반환된 데이터와 비교하는 방식을 사용했다.)

물론 ethers 와 같은 라이브러리를 사용했을 때 해당 컨트랙트의 ABI로 인스턴스를 생성하면 기본적으로 커스텀 에러에 대한 인터페이스도 포함되어있을 것이다. 하지만 해당 컨트랙트 함수 안에서 외부의 컨트랙트 함수를 호출했을 때 외부 컨트랙트에서 커스텀 에러를 통해 예외가 발생했다면, 우리의 컨트랙트 ABI 안에 해당 커스텀 에러 인터페이스까지 포함되어있을 경우는 드물기 때문에 해당 에러 정보를 파악하는데 어려움이 생긴다.

따라서 커스텀 에러에 대한 좀 더 원활한 헨들링을 위해서는 작성한 컨트랙트 안에서 상호작용하는 컨트랙트들에 대한 포괄적인 error 인터페이스 정보를 구축하는 것이 필요하다. 보통 회사에서 디앱을 구축하는 상황에서는 팀에서 직접 작성한 여러 컨트랙트가 상호작용하는 경우가 많기 때문에, 팀에서 작성한 모든 컨트랙트의 error 인터페이스 정보를 기반으로 디코딩 함수를 작성하는 것이 효율적이다.

(* 또한 이 지점에서 한 가지 유의해야할 지점은, 반환된 에러 데이터로는 해당 에러 데이터의 출처가 추적되지 않는다는 점이다. 따라서 외부의 컨트랙트 함수를 호출했을 때, 해당 컨트랙트에서 동일한 커스텀 에러 시그니처를 위조하여 반환함으로써 에러 상황을 위조할 수 있다. 때문에 클라이언트 입장에서 호출한 컨트랙트 함수 안에 검증되지 않는 외부 컨트랙트 함수를 호출하는 로직이 포함되어있다면 미리 정의된 error 시그니처를 에러로 반환하더라도 해당 데이터를 완전히 신뢰하여 에러 상황을 유추해서는 안 된다.)

import { Interface } from "ethers/lib/utils";

export const getErrorData = (contractInterfaces: Interface[]) => {
const errorData = {} as any;

for (let i = 0; i < contractInterfaces.length; i++) {
for (const [signature, value] of Object.entries(
contractInterfaces[i].errors
)) {
const id = ethers.utils.id(signature).slice(0, 10);
if (!errorData[id]) {
errorData[id] = value.name;
}
}
}

return errorData;
};

나의 경우에는 현재 프로젝트에서 파라미터를 포함하는 커스텀 에러를 사용하고 있지 않기 때문에, 위의 함수에서처럼 컨트랙트 인터페이스 배열을 넣어주면 모든 error 의 시그니처 값을 key 값으로 갖고 error 이름을 value 갖는 object를 생성해주는 함수를 만들어서 사용중이다.

컨트랙트를 배포한 이후에 위 함수를 통해 프로젝트에서 사용하는 모든 컨트랙트에 대한 커스텀 에러 정보를 갖고 있는 object를 생성하는 것이다.

const CustomErrorData = {
'0x00bfc921': 'InvalidPrice',
'0x0c5317cc': 'AlreadyExistTokenContract',
// ...
'0x3f6cc768': 'InvalidTokenId',
'0xb7d09497': 'InvalidTimestamp',
'0xba83c474': 'DuplicateAccount',
};

const customErrorName = CustomErrorData[errorData.slice(0, 10)];

그리고 위의 코드에서처럼 컨트랙트 호출이 필요한 백엔드 및 프론트에서 해당 object를 기반으로 커스텀 에러명을 식별하는 방식을 사용하고 있다. 현재 프로젝트 컨트랙트에서 발생시키는 예외는 모두 커스텀 에러를 사용하고 있기 때문에 이 방식을 사용하면 자체적으로 발생시킨 대부분의 예외에 대해서는 외부에서도 편리하게 식별이 가능하다.

정리

이제 마지막으로 에러 종류에 따라 분리되어있던 디코딩 함수를 하나의 함수로 합쳐보자.

import { AssertionError } from "assert";
import { ethers, BigNumber } from "ethers";
import { defaultAbiCoder } from "ethers/lib/utils";

// method id of 'Error(string)'
const ERROR_STRING_PREFIX = "0x08c379a0";
// method id of 'Panic(uint256)'
const PANIC_CODE_PREFIX = "0x4e487b71";
const UNKNOWN_ERROR = "Unknown Error";

const panicErrorCodeToReason = (errorCode: BigNumber): string | undefined => {
switch (errorCode.toNumber()) {
case 0x1:
return "Assertion error";
case 0x11:
return "Arithmetic operation underflowed or overflowed outside of an unchecked block";
case 0x12:
return "Division or modulo division by zero";
case 0x21:
return "Tried to convert a value into an enum, but the value was too big or negative";
case 0x22:
return "Incorrectly encoded storage byte array";
case 0x31:
return ".pop() was called on an empty array";
case 0x32:
return "Array accessed at an out-of-bounds or negative index";
case 0x41:
return "Too much memory was allocated, or an array was created that is too large";
case 0x51:
return "Called a zero-initialized variable of internal function type";
}
};

const CustomErrorData = {
'0x00bfc921': 'InvalidPrice',
'0x0c5317cc': 'AlreadyExistTokenContract',
// ...
'0x3f6cc768': 'InvalidTokenId',
'0xb7d09497': 'InvalidTimestamp',
'0xba83c474': 'DuplicateAccount',
};

const decodeErrorData = (error: any): string => {
if (!(error instanceof Error)) {
return UNKNOWN_ERROR;
}

error = error as any;

if (error.errorName) {
return error.errorName;
}

const errorData =
error.data ?? error.error?.data ?? error.error?.error?.error?.data;

if (errorData === undefined) {
return error.message ? error.message : UNKNOWN_ERROR;
}

const returnData =
typeof errorData === 'string' ? errorData : errorData.data;

if (returnData === undefined || typeof returnData !== 'string') {
return UNKNOWN_ERROR;
} else if (returnData === '0x') {
return UNKNOWN_ERROR;
} else if (returnData.startsWith(ERROR_STRING_PREFIX)) {
const encodedReason = returnData.slice(ERROR_STRING_PREFIX.length);
let reason: string;

try {
reason = defaultAbiCoder.decode(['string'], `0x${encodedReason}`)[0];
} catch (err) {
return UNKNOWN_ERROR;
}

return reason;
} else if (returnData.startsWith(PANIC_CODE_PREFIX)) {
const encodedReason = returnData.slice(PANIC_CODE_PREFIX.length);
let code: BigNumber;

try {
code = defaultAbiCoder.decode(['uint256'], `0x${encodedReason}`)[0];
} catch (err) {
return UNKNOWN_ERROR;
}

return (
panicErrorCodeToReason(code) ?? 'unknown panic code'
);
}

const customErrorName = CumstomErrorData[returnData.slice(0, 10)];

if (customErrorName === undefined) {
return UNKNOWN_ERROR;
}
return customErrorName;
}
};

decodeErrorData() 함수는 ethers 를 통해 컨트랙트 함수 호출시 에러가 발생했을 때, 반환된 에러를 인자로 받는다. 1차적으로 error.errorName 에 값이 있다면 ethers 자체에서 이미 디코딩된 에러 정보가 존재하는 것이기 때문에 그대로 리턴한다.

만약 errorName 이 존재하지 않는다면 디코딩할 에러 데이터를 추출하고, 에러 데이터가 없다면 error.message 혹은 UNKNOWN_ERROR 를 리턴한다.

에러 데이터를 추출한 이후부터는 Error(string), Panic(uint256) 유형에 해당하는지에 따라 Revert, Require, Assert 에러에 맞게 데이터를 디코딩하며 위 유형들에 해당하지 않는다면 마지막으로 커스텀 에러인지를 확인한다.


이렇게 컨트랙트 내부에서 발생되는 에러의 종류와 특징들에 대해 알아보고, 에러 종류에 따라 외부에서 식별하는 방식을 살펴보았다. 에러에 관한 데이터 또한 기본적으로 ABI 스펙을 기준으로 이루어지기 때문에, 컨트랙트 내부의 에러 상황을 외부에서 원활하게 파악하기 위해서는 역시 컨트랙트 내에서 해당 에러가 발생되는 과정과 데이터가 인코딩되는 방식이 중요하게 고려돼야 한다.

글에서 언급된 예시코드는 링크로 들어가면 볼 수 있고, 직접 테스트 결과를 확인해볼 수 있다.