티스토리 뷰
컨트랙트 ABI란?
컨트랙트 ABI(Application Binary Interface)는 이더리움 생태계에서 컨트랙트와의 상호작용을 위한 표준방식이다. ABI는 스마트 컨트랙트의 함수명, 매개 변수의 타입 및 반환 값의 타입을 설명한다.
일반적으로 solidity로 작성된 코드가 컴파일될 때 ABI가 생성되며, 이는 오프체인에서 컨트랙트로의 상호작용 또는 컨트랙트에서 컨트랙트로의 상호작용에 사용된다. 컨트랙트의 호출에 사용되는 calldata가 바로 ABI 형식으로 인코딩 된 데이터다.
타입별 ABI 인코딩
컨트랙트와 상호작용하기 위해 데이터는 ABI 형식에 맞춰 인코딩이 필요하다.
solidity의 내장 함수인 abi.encode를 사용해 인코딩이 어떻게 이루어지는지 알아보자.
정적 타입
32바이트 크기의 16진수 문자열로 인코딩 된다는 공통점
uint, uint<M>
바이트 단위 크기의 부호가 없는 정수 (M % 8 = 0)
function encodeUint112(uint112 n) public pure returns (bytes memory) {
return abi.encode(n);
}
빅 엔디언 정수로 인코딩하고 32바이트 중 비어있는 상위 비트는 모두 0으로 채운다.
encodeUint(4875)
0x000000000000000000000000000000000000000000000000000000000000130b
int, int<M>
바이트 단위 크기의 부호가 있는 정수 (M % 8 = 0)
function encodeInt8(int8 n) public pure returns (bytes memory) {
return abi.encode(n);
}
부호가 없는 정수와 동일. 다만 음수의 인코딩은 단순히 양수값에서 모든 비트를 뒤집은 값을 사용한다.
encodeInt8(5)
0x0000000000000000000000000000000000000000000000000000000000000005
encodeInt8(-5)
0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffb
bytes<M>
M은 1~32 사이의 값.
function encodeBytes4(bytes4 b) public pure returns (bytes memory) {
return abi.encode(b);
}
32 바이트 중 상위 M 바이트를 채우고 남은 바이트는 0으로 채운다.
encodeBytes4(0xa1b2c3d4)
0xa1b2c3d400000000000000000000000000000000000000000000000000000000
address, address payable, contract
20 바이트 크기의 16진수 문자열
function encodeAddress(address a) public pure returns (bytes memory) {
return abi.encode(a);
}
function encodeIERC20(IERC20 i) public pure returns (bytes memory) {
return abi.encode(i);
}
상위 12 바이트를 0으로 채우고 그 뒤에 주소를 채워 넣는다.
encodeAddress(0x965B0E63e00E7805569ee3B428Cf96330DFc57EF)
0x000000000000000000000000965b0e63e00e7805569ee3b428cf96330dfc57ef
encodeERC20(0x779877A7B0D9E8603169DdbD7836e478b4624789)
0x000000000000000000000000779877a7b0d9e8603169ddbd7836e478b4624789
boolean
1 바이트 크기의 uint8 타입 정수 0 또는 1로 분류
function encodeBool(bool b) public pure returns (bytes memory) {
return abi.encode(b);
}
encodeBool(true)
0x0000000000000000000000000000000000000000000000000000000000000001
encodeBool(false)
0x0000000000000000000000000000000000000000000000000000000000000000
function
function transfer(address to, uint256 value) public virtual returns (bool) {
...
}
function encodeFunction() public view returns (bytes memory) {
return abi.encode(this.transfer);
}
앞에서부터 컨트랙트 주소 20 바이트 + transfer 함수의 선택자 4 바이트(0xa9059cbb) + 0으로 채워진 나머지 8 바이트
encodeFunction()
0x // prefix
da0bab807633f07f013f94dd0e6a4f96f8742b53 // contract address
a9059cbb // function selector
0000000000000000 // padding
array
function encodeUintArray(uint[2] calldata arr) public pure returns (bytes memory) {
return abi.encode(arr);
}
function encodeBytes4Array(bytes4[2] calldata arr) public pure returns (bytes memory) {
return abi.encode(arr);
}
원소 각각을 ABI 인코딩한 결과를 이어 붙인 모양. 길이는 매개변수 타입으로 명시되어 있으므로 별도로 추가하지 않는다.
encodeUintArray([1, 2])
0x // prefix
0000000000000000000000000000000000000000000000000000000000000001 // 1
0000000000000000000000000000000000000000000000000000000000000002 // 2
encodeBytes4Array(["0x12345678","0x98765432"])
0x // prefix
1234567800000000000000000000000000000000000000000000000000000000 // 0x12345678
9876543200000000000000000000000000000000000000000000000000000000 // 0x98765432
동적 타입
bytes
function encodeBytes(bytes calldata b) public pure returns (bytes memory) {
return abi.encode(b);
}
- offset 32 바이트 + 바이트열의 길이를 나타내는 32 바이트 + 실제 바이트열.
- offset은 몇 바이트 뒤에 실제 데이터가 시작되는지를 가리키는 값.
- 바이트열의 길이가 32의 배수가 아닌 경우, 32의 배수가 되도록 뒤에 0을 채워 넣는다.
encodeBytes(0x12345678)
0x // prefix
0000000000000000000000000000000000000000000000000000000000000020 // offset
0000000000000000000000000000000000000000000000000000000000000004 // length
1234567800000000000000000000000000000000000000000000000000000000 // bytes
string
function encodeString(string calldata s) public pure returns (bytes memory) {
return abi.encode(s);
}
bytes와 동일하게 동작.
encodeString("Hello World")
0x // prefix
0000000000000000000000000000000000000000000000000000000000000020 // offset
000000000000000000000000000000000000000000000000000000000000000b // length
48656c6c6f20576f726c64000000000000000000000000000000000000000000 // string
dynamic array
function encodeDynamicUintArray(uint[] calldata arr) public pure returns (bytes memory) {
return abi.encode(arr);
}
function encodeDynamicStringArray(string[] calldata arr) public pure returns (bytes memory) {
return abi.encode(arr);
}
- 정적 타입을 원소로 가진 동적 배열인 경우
- 동적 배열의 offset 32 바이트 + 원소의 개수 32 바이트 + 각 원소를 인코딩한 결과를 연결한 값 (32 바이트씩)
encodeDynamicUintArray([1,2,3])
0x // prefix
0000000000000000000000000000000000000000000000000000000000000020 // offset of array
0000000000000000000000000000000000000000000000000000000000000003 // the number of elements
0000000000000000000000000000000000000000000000000000000000000001 // 1
0000000000000000000000000000000000000000000000000000000000000002 // 2
0000000000000000000000000000000000000000000000000000000000000003 // 3
- 동적 타입을 원소로 가진 동적 배열인 경우
- 동적 배열의 offset 32 바이트 + 원소의 개수 32 바이트 + 각 원소의 offset 32 바이트 * 원소의 개수 + (각 원소의 길이 + 원소) * 원소의 개수
encodeDynamicStringArray(["hello", "world"])
0x // prefix
0000000000000000000000000000000000000000000000000000000000000020 // offset of array
0000000000000000000000000000000000000000000000000000000000000002 // the number of elements
0000000000000000000000000000000000000000000000000000000000000040 // offset of "hello"
0000000000000000000000000000000000000000000000000000000000000080 // offset of "world"
0000000000000000000000000000000000000000000000000000000000000005 // length of "hello"
68656c6c6f000000000000000000000000000000000000000000000000000000 // "hello"
0000000000000000000000000000000000000000000000000000000000000005 // length of "world"
776f726c64000000000000000000000000000000000000000000000000000000 // "world"
function encodeDynamic2DStringArray(string[][] calldata arr) public pure returns (bytes memory) {
return abi.encode(arr);
}
- 동적 타입을 원소로 가진 N차원 동적 배열인 경우
- 재귀적으로 원소의 개수 + 각 원소의 offset + (원소의 길이 + 원소)
encodeDynamic2DStringArray([["hello", "world"], ["goodbye"]])
0x // prefix
// array
0000000000000000000000000000000000000000000000000000000000000020 // offset of array
0000000000000000000000000000000000000000000000000000000000000002 // the number of elements
0000000000000000000000000000000000000000000000000000000000000040 // offset of first element
0000000000000000000000000000000000000000000000000000000000000120 // offset of second element
// array[0]
0000000000000000000000000000000000000000000000000000000000000002 // the number of elements
0000000000000000000000000000000000000000000000000000000000000040 // offset of first element
0000000000000000000000000000000000000000000000000000000000000080 // offset of second element
0000000000000000000000000000000000000000000000000000000000000005 // length of first element
68656c6c6f000000000000000000000000000000000000000000000000000000 // first element
0000000000000000000000000000000000000000000000000000000000000005 // length of second element
776f726c64000000000000000000000000000000000000000000000000000000 // second element
// array[1]
0000000000000000000000000000000000000000000000000000000000000001 // the number of elements
0000000000000000000000000000000000000000000000000000000000000020 // offset of first element
0000000000000000000000000000000000000000000000000000000000000007 // length of first element
676f6f6462796500000000000000000000000000000000000000000000000000 // first element
map
맵은 인코딩이 불가능하다.
tuple
맵과 마찬가지로 튜플을 직접 인코딩하는 것은 불가능하다.
사용자 정의 타입
enum
enum Alphabet {
A,
B,
C
}
function encodeEnum(Alphabet abc) public pure returns (bytes memory) {
return abi.encode(abc);
}
enum은 uint8로 취급한다. 따라서 빅 엔디언으로 인코딩하고 32 바이트 중 비어 있는 상위 비트는 0으로 채운다.
encodeEnum(2)
0x0000000000000000000000000000000000000000000000000000000000000002
struct
튜플을 인코딩하는 것은 불가능한데 구조체는 튜플로써 인코딩 된다(?).
struct Person {
string name;
uint age;
}
function encodeStruct(Person calldata person) public pure returns (bytes memory) {
return abi.encode(person);
}
그런데 구조체의 인코딩은 뭔가 순서가 맞지 않다. 분명 name이 먼저 오고 그다음이 age인데 인코딩 된 결과에는 age가 앞에 오는 것을 볼 수 있다.
encodeStruct(["Mario", 38])
0x // prefix
0000000000000000000000000000000000000000000000000000000000000020 // offset of struct
0000000000000000000000000000000000000000000000000000000000000040 // offset of string data
0000000000000000000000000000000000000000000000000000000000000026 // 38
0000000000000000000000000000000000000000000000000000000000000005 // length of string
4d6172696f000000000000000000000000000000000000000000000000000000 // string data
go-ethereum 패키지를 사용해 구조체를 인코딩하면 다음과 같다.
package main
import (
"fmt"
"math/big"
"strings"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common/hexutil"
)
func main() {
personStruct, _ := abi.NewType(
"tuple",
"person",
[]abi.ArgumentMarshaling{
{
Name: "name",
Type: "string",
},
{
Name: "age",
Type: "uint256",
},
},
)
record := struct {
Name string
Age *big.Int
}{
Name: "Mario",
Age: big.NewInt(38),
}
args := abi.Arguments{
{
Type: personStruct,
Name: "person",
},
}
encoded, err := args.Pack(record)
if err != nil {
panic(err)
}
expected := "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000002600000000000000000000000000000000000000000000000000000000000000054d6172696f000000000000000000000000000000000000000000000000000000"
if !strings.EqualFold(hexutil.Encode(encoded), expected) {
panic("encoded data does not match the expected value")
}
fmt.Println("encoded data matches the expected value")
}
결과가 동일한 것을 확인할 수 있다.
$ go run .
encoded data matches the expected value
문자열 필드를 하나 추가해서 인코딩해 보자.
func main() {
personStruct, _ := abi.NewType(
"tuple",
"person",
[]abi.ArgumentMarshaling{
{
Name: "name",
Type: "string",
},
{
Name: "city",
Type: "string",
},
{
Name: "age",
Type: "uint256",
},
},
)
record := struct {
Name string
City string
Age *big.Int
}{
Name: "Mario",
City: "Mushroom Kingdom",
Age: big.NewInt(38),
}
args := abi.Arguments{
{
Type: personStruct,
Name: "person",
},
}
encoded, err := args.Pack(record)
if err != nil {
panic(err)
}
fmt.Println(hexutil.Encode(encoded))
}
결과는 다음과 같다. 이번에도 38이 가장 앞에 위치한다. 중간에 보이지 않는 정렬 과정이 포함되어 있는 것 같은데, 이는 go-ethereum 패키지를 분석하면서 차차 살펴보자.
$ go run .
0x // prefix
0000000000000000000000000000000000000000000000000000000000000020 // offset of struct
0000000000000000000000000000000000000000000000000000000000000060 // offset of "Mario"
00000000000000000000000000000000000000000000000000000000000000a0 // offset of "Mushroom Kingdom"
0000000000000000000000000000000000000000000000000000000000000026 // 38
0000000000000000000000000000000000000000000000000000000000000005 // length of "Mario"
4d6172696f000000000000000000000000000000000000000000000000000000 // "Mario"
0000000000000000000000000000000000000000000000000000000000000010 // length of "Mushroom Kingdom"
4d757368726f6f6d204b696e67646f6d00000000000000000000000000000000 // "Mushroom Kingdom"
calldata를 구성하는 방법
solidity 상에서 calldata를 구성하는 방법은 세 가지가 있다. (더 있을지도 모름)
ERC-20 토큰의 transfer 함수를 호출하는 calldata를 구성해 보자.
1. abi.encodeWithSignature
먼저 abi.encodeWithSignature를 사용하는 방법이다. 이 방법은 다음과 같이 사용한다. 호출하고자 하는 함수의 시그니처를 가장 앞에 넣고 그 뒤에는 함수 호출에 필요한 파라미터를 차례로 넣는다. 파라미터가 불필요한 함수도 있으므로, n은 0일 수도 있다.
abi.encodeWithSignature(<호출하고자 하는 함수의 시그니처>, 함수 파라미터 1, 파라미터 2, ... 파라미터 n)
함수의 시그니처는 다음과 같다. 파라미터가 불필요한 함수는 '()'로 남겨두면 된다.
<함수 이름>(<파라미터 1의 타입>,<파라미터 2의 타입>,...,<파라미터 n의 타입>)
transfer 함수를 호출하기 위한 calldata를 구하는 함수는 다음과 같다.
function calldataForTransferWithSignature(address to, uint256 amount) public pure returns (bytes memory) {
return abi.encodeWithSignature("transfer(address,uint256)", to, amount);
}
address가 0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2이고 amount가 100일 때, 반환되는 값은 다음과 같다.
0x // prefix
a9059cbb // function selector
000000000000000000000000ab8483f64d9c6d1ecf9b849ae677dd3315835cb2 // address
0000000000000000000000000000000000000000000000000000000000000064 // amount
함수의 선택자는 함수 시그니처를 입력으로 구한 keccak256 해시의 상위 4바이트를 가리킨다.
function trasnferSelector() public pure returns (bytes4) {
return bytes4(keccak256("transfer(address,uint256)"));
}
Remix IDE에서 다음 컨트랙트를 테스트 환경에 배포하고 앞서 생성한 calldata로 low level interaction을 실행해 보자.
contract TransferTest is ERC20 {
constructor() ERC20("TEST", "TEST") {
_mint(msg.sender, 10e20);
}
function transferKeccak() public pure returns (bytes32) {
return keccak256("transfer(address,uint256)");
}
function calldataForTransferWithSignature(address to, uint256 amount) public pure returns (bytes memory) {
return abi.encodeWithSignature("transfer(address,uint256)", to, amount);
}
function trasnferSelector() public pure returns (bytes4) {
return bytes4(keccak256("transfer(address,uint256)"));
}
receive() external payable {}
fallback() external payable {}
}
transfer 함수가 정상적으로 동작하는 것을 확인할 수 있다.
2. abi.encodeWithSelector
abi.encodeWithSignature와 거의 비슷하다. 다만 이번에는 함수의 시그니처가 아닌 선택자를 바로 넘겨준다.
function calldataForTransferWithSelector(address to, uint256 amount) public pure returns (bytes memory) {
return abi.encodeWithSelector(ERC20.transfer.selector, to, amount);
}
address가 0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2이고 amount가 100일 때, 반환되는 값은 다음과 같다.
0xa9059cbb000000000000000000000000ab8483f64d9c6d1ecf9b849ae677dd3315835cb20000000000000000000000000000000000000000000000000000000000000064
마찬가지로 transfer 함수가 정상적으로 동작하는 것을 확인할 수 있다.
3. abi.encodePacked
abi.encodePacked는 abi.encode와 유사하지만 문자열과 바이트열 같은 동적 타입의 값을 인코딩하지 않고 단순히 이어 붙인다는 점에서 다르다.
예를 들어 다음 함수를 살펴보자. encode 함수는 문자열 a와 b를 정석대로 인코딩하는데, encodePacked 함수는 이들을 단순히 이어 붙여 버린다.
function encode(string calldata a, string calldata b) public pure returns (bytes memory) {
return abi.encode(a, b);
}
function encodePacked(string calldata a, string calldata b) public pure returns (bytes memory) {
return abi.encodePacked(a, b);
}
encode("mario", "luigi")
0x0000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000056d6172696f00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000056c75696769000000000000000000000000000000000000000000000000000000
encodePacked("mario", "luigi")
0x74657374317465737432
abi.encodePacked 함수는 일부 연산에서 사용되는 바이트 수를 줄여서 가스비를 줄일 수 있다는 장점이 있다. 그러나 다음과 같은 문제가 발생할 수 있다. 'mario'에서 'o'를 'luigi'의 앞에 갖다 붙인다고 해서 인코딩 결과가 달라지지 않는다. 따라서 이러한 값을 해시 함수의 입력으로 사용하게 되면 누구든 쉽게 충돌을 찾을 수 있다.
encodePacked("mario", "luigi")
0x74657374317465737432
encodePacked("mari", "oluigi")
다시 돌아가서, abi.encodePacked를 사용하려면 일부 값들을 abi.encode를 사용해 적절히 인코딩해줄 필요가 있다.
function calldataForTransferWithEncodePacked(address to, uint256 amount) public pure returns (bytes memory) {
return abi.encodePacked(ERC20.transfer.selector, abi.encode(to), abi.encode(amount));
}
address가 0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2이고 amount가 100일 때, 반환되는 값은 다음과 같다.
0xa9059cbb000000000000000000000000ab8483f64d9c6d1ecf9b849ae677dd3315835cb20000000000000000000000000000000000000000000000000000000000000064
이번에도 transfer 함수가 정상적으로 동작하는 것을 확인할 수 있다.
ABI 디코딩
ABI 형식의 데이터를 온체인 상에서도 디코딩이 가능하다.
abi.decode(<인코딩된 데이터>, (<디코딩될 데이터 타입1>, <디코딩될 데이터 타입2>, ..., <디코딩될 데이터 타입n>));
name이 'Mario'이고 age가 38인 Person 구조체의 인코딩 결과는 다음과 같다.
0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000002600000000000000000000000000000000000000000000000000000000000000054d6172696f000000000000000000000000000000000000000000000000000000
이 값을 다음 함수의 입력으로 사용해 ABI 형식으로 인코딩 된 Person 구조체를 디코딩할 수 있다.
function decodeStruct(bytes memory data) public pure returns(Person memory person) {
person = abi.decode(data, (Person));
}
함수 실행 결과 올바른 값이 출력되는 것을 확인할 수 있다.
전체 코드
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;
import '@openzeppelin/contracts/token/ERC20/ERC20.sol';
contract Encoder is ERC20 {
constructor() ERC20("TEST", "TEST") {}
function encodeUint(uint n) public pure returns (bytes memory) {
return abi.encode(n);
}
function encodeUint112(uint112 n) public pure returns (bytes memory) {
return abi.encode(n);
}
function encodeInt(int n) public pure returns (bytes memory) {
return abi.encode(n);
}
function encodeInt8(int8 n) public pure returns (bytes memory) {
return abi.encode(n);
}
function encodeBytes32(bytes32 b) public pure returns (bytes memory) {
return abi.encode(b);
}
function encodeBytes4(bytes4 b) public pure returns (bytes memory) {
return abi.encode(b);
}
function encodeAddress(address a) public pure returns (bytes memory) {
return abi.encode(a);
}
function encodeERC20(ERC20 e) public pure returns (bytes memory) {
return abi.encode(e);
}
function encodeBool(bool b) public pure returns (bytes memory) {
return abi.encode(b);
}
function encodeFunction() public view returns (bytes memory) {
return abi.encode(this.transfer);
}
function encodeUintArray(uint[2] calldata arr) public pure returns (bytes memory) {
return abi.encode(arr);
}
function encodeBytes4Array(bytes4[2] calldata arr) public pure returns (bytes memory) {
return abi.encode(arr);
}
function encodeBytes(bytes calldata b) public pure returns (bytes memory) {
return abi.encode(b);
}
function encodeString(string calldata s) public pure returns (bytes memory) {
return abi.encode(s);
}
function encodeDynamicUintArray(uint[] calldata arr) public pure returns (bytes memory) {
return abi.encode(arr);
}
function encodeDynamicStringArray(string[] calldata arr) public pure returns (bytes memory) {
return abi.encode(arr);
}
function encodeDynamic2DStringArray(string[][] calldata arr) public pure returns (bytes memory) {
return abi.encode(arr);
}
function encodeTuple(uint a, address b) public pure returns (bytes memory) {
return abi.encode(a, b);
}
function encodeTuple2(uint a, string calldata b) public pure returns (bytes memory) {
return abi.encode(a, b);
}
// mapping(uint => address) encMap;
// function encodeMap() public returns (bytes memory) {
// encMap[1] = address(0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2);
// encMap[2] = address(0x4B20993Bc481177ec7E8f571ceCaE8A9e22C02db);
// return abi.encode(encMap);
// }
enum Alphabet {
A,
B,
C
}
function encodeEnum(Alphabet abc) public pure returns (bytes memory) {
return abi.encode(abc);
}
struct Person {
string name;
uint age;
}
function encodeStruct(Person calldata person) public pure returns (bytes memory) {
return abi.encode(person);
}
}
contract TransferTest is ERC20 {
constructor() ERC20("TEST", "TEST") {
_mint(msg.sender, 10e20);
}
function transferKeccak() public pure returns (bytes32) {
return keccak256("transfer(address,uint256)");
}
function calldataForTransferWithSignature(address to, uint256 amount) public pure returns (bytes memory) {
return abi.encodeWithSignature("transfer(address,uint256)", to, amount);
}
function calldataForTransferWithSelector(address to, uint256 amount) public pure returns (bytes memory) {
return abi.encodeWithSelector(ERC20.transfer.selector, to, amount);
}
function calldataForTransferWithEncodePacked(address to, uint256 amount) public pure returns (bytes memory) {
return abi.encodePacked(ERC20.transfer.selector, abi.encode(to), abi.encode(amount));
}
function trasnferSelector() public pure returns (bytes4) {
return bytes4(keccak256("transfer(address,uint256)"));
}
receive() external payable {}
fallback() external payable {}
}
contract EncComp {
function encode(string calldata a, string calldata b) public pure returns (bytes memory) {
return abi.encode(a, b);
}
function encodePacked(string calldata a, string calldata b) public pure returns (bytes memory) {
return abi.encodePacked(a, b);
}
}
contract Decoder {
struct Person {
string name;
uint age;
}
function decodeStruct(bytes memory data) public pure returns(Person memory person) {
person = abi.decode(data, (Person));
}
}
참고
'블록체인 > Ethereum' 카테고리의 다른 글
ERC-4337: 계정 추상화 - 테스트 수정 사항 (0) | 2024.04.17 |
---|---|
ERC-4337: 계정 추상화 - 테스트를 통한 Account와 EntryPoint의 동작 이해 (0) | 2024.04.17 |
ERC-4337: 계정 추상화 (0) | 2024.04.16 |
소셜 로그인 + 계정 추상화를 사용한 dApp 만들기 (0) | 2024.04.13 |
RLP (Recursive Length Prefix) 직렬화 이해하기 (1) | 2024.01.25 |