이더리움 주소는 Keccak-256 단방향 해시 함수를 사용하는 공개키 또는 컨트랙트에서 파생한 고유식별자다.
이더리움에는 두가지 계정의 유형이 있다.
1. 외부 소유 계정 (Externally Owned Account, EOA)
2. 컨트랙트 계정 (Contract Account, CA)
간단하게, 외부 소유 계정은 '이더리움' 은행에서 생성된 '계좌번호'와 같은 개념이고,
컨트랙트 계정은 이름에서 알 수 있듯이, 스마트 컨트랙트가 실제로 실행되는 계정이다. 이때의 계정은 컨트랙트가 배포될 때 생성되고 이 주소로 블록체인 상에서 컨트랙트를 확인할 수 있다.
두 유형 모두 키값 저장소와 이더 잔액 형태로 데이터를 저장하고 있다.
📌 메타마스크 지갑에서 생성한 계정은 EOA이다. 외부 소유 계정은 개인키가 있는 계정이고, 개인키를 갖는다는 것은 자금 또는 컨트랙트에 대한 접근을 제어한다는 뜻이다.
컨트랙트 계정에는 비교적 단순한 EOA가 가질 수 없는 스마트 컨트랙트 코드가 있다. 또한 컨트랙트 계정에는 개인키가 없다. 대신, 컨트랙트 계정은 스마트 컨트랙트 코드의 로직으로 제어한다. 여기서 스마트 컨트랙트 코드는 컨트랙트 계정 생성 시 이더리움 블록체인에 기록되고 EVM에 의해 실행되는 소프트웨어 프로그램이다.
- 마스터링 이더리움
EOA와 CA 비교
속성 | EOA | CA |
---|---|---|
이더 잔액이 있는가 | 예 | 예 |
트랜잭션 메시지를 실행할 수 있는가 | 예 | 아니오 |
메시지를 부를 수 있는가 | 아니오 | 예 |
프로그램 코드가 있는가 | 아니오 | 예 |
표에서 가장 중요한 부분은, 트랜잭션 메시지를 실행할 수 있는가의 부분이다.
트랜잭션 메시지 라는것은 실제 트랜잭션을 일으키는 목적이 담긴 부분이라고 볼 수도 있는데, 예를 들면, 이더 전송
이라는 트랜잭션을 발생시킨다면, 이더 전송
이라는 목적을 달성하기 위해 트랜잭션 메시지에는 프로그램 코드에서 전송
이라는 코드를 사용할 것이라는 내용을 담겨 있을 것이다. (하지만 프로그램 코드에 전송
에 해당하는 코드가 없다면, 이 트랜잭션은 revert 될 것이다.)
이처럼 EOA는 모든 트랜잭션의 시작이 될 수 있지만, CA는 트랜잭션의 시작이 될 수 없다.
트랜잭션을 직접 실행 할 수 없는 것이다.
트랜잭션은 반드시 EOA를 통해 발생하게 되며 트랜잭션을 만들때 사용된 EOA에서 가스비를 지불하게 된다.
(트랜잭션 발생 당시 가스비가 부족해도 바로 revert가 된다.)
하지만 EOA는 트랜잭션을 발생시킬 수 있지만, 코드를 저장하고 있지않기 때문에 코드를 실행시킬 수 는 없다.
따라서 프로그램 코드를 보유하고 있는 CA를 호출하는 트랜잭션을 발생시키고, 해당 메세지의 실제 실행은 코드를 저장하고 있는 CA에서 일어나게 된다.
(또한 CA는 EOA가 발생시킨 트랜잭션 안에서 또다른 CA를 호출 할 수도 있다.)
위의 그림과 모든 트랜잭션은 EOA로 부터 시작되며, CA는 EOA에 의해 전달받은 메시지에 대한 응답으로 자신이 가지고 있는 코드를 통해 목적을 달성하는 것이다.
그렇다면 EOA는 어떻게 만들어지는 것일까?
대부분의 블록체인 시스템처럼 이더리움의 보안은 공개키 암호화를 기반으로 한다.
하나의 계정은 한 쌍의 개인키,공개키로 식별되며 계정 주소는 공개키의 마지막 20바이트로 표시된다.
계정의 개인키/공개키 쌍은 텍스트 키파일에 저장된다. 공개키는 일반 텍스트로 보이지만 개인키는 계정을 생성할 때 만든 비밀번호로 암호화되어 있다.
(계정의 키파일은 이더리움 노드 데이터 경로인 keystore 폴더에 존재한다.)
1️⃣ 지갑 프로그램을 통해 개인키를 생성하는 과정은 랜덤한 256bit 데이터를 생성하고, 생성된 256bit의 데이터를 64자리의 Hex열로 인코딩한 결과값이다. (256/8=32바이트, 16진수로 64자리로 표현)
2️⃣ 개인키를 통해 ECDSA(타원곡선 전사서명 알고리즘)을 이용하여 공개키를 생성한다.
3️⃣ 생성된 공개키를 Keccak256 Hash값으로 변환, 256bit의 바이너리 데이터가 생성되며, 생성된 바이너리 데이터를 Hex열 값으로 인코딩한 결과값이 바로 Address 정보이다. (160bit를 16진수로 변환, 20byte의 데이터이며, 16진수로는 40개 문자열을 표현할 수 있음)
EOA계정 생성 방법은 4가지가 있다.
여기서는 첫번째와 마지막 방법을 빼고, geth명령어, geth 콘솔로 계정을 관리하는 방법을 짧게 알아볼 것이다.
geth 설치 방법
콘솔창을 활용하여 geth 실행 파일의 디렉터리에서 geth 명령어로 쉽게 계정을 관리할 수 있다.
> geth account new
b. 비밀번호를 두 번 입력하면 생성한 계정의 주소를 확인할 수 있다.> geth --password password_directory/passwordfile account new
만들어진 계정은 geth account list
명령어를 통해 확인할 수 있다.
대화형 geth 콘솔에서 web3.personal 객체를 사용하여 계정을 만들 수 있다.
terminal 1
> geth console //대화형 콘솔 열기
INFO [09-08|17:32:53.303] IPC endpoint opened url=/Users/******/Library/Ethereum/geth.ipc //url뒤의 주소 확인
terminal 2
> geth attach ipc:/Users//******//Library/Ethereum/geth.ipc //명령어 입력
> personal.newAccount()
원래 해당 명령어를 통해 새로운 계정 생성이 가능했으나, 지금은 막혀있는것으로 파악됨ㅠ
address
account 마다 고유하게 가지고 있는 20bytes의 숫자이자 42자리 문자열(0xABC...) 우리가 흔히 '주소'라고 이야기 할 때의 값을 의미
nonce
account가 전송한 트랜잭션의 개수를 나타내는 정수.
account가 트랜잭션을 전송할 때, 현재 계정의 nonce를 같이 첨부하여, 트랜잭션이 중복되어 전송되지 않았음을 보장하기 위해 사용한다. 컨트랙트의 경우, 트랜잭션을 전송할 수 없기 때문에 CA의 nonce는 해당 컨트랙트가 CREATE OPCODE를 통해 생성한 컨트랙트의 개수를 의미한다.
balance
해당 account가 보유하고 있는 ether balance를 나타내는 필드이다.
codeHash
CA라면, codeHash 필드에 스마트 컨트랙트 코드가 담긴다. 그러나 EOA는 해당 값에 빈 문자열("")의 해시값이 담겨 있다.
storageRoot
각 account는 storage trie를 가지고 있는데, 이 storage trie는 컨트랙트의 데이터를 담고 있는 trie이다. 이 trie의 root node를 해시한 값이 storageRoot이다.
default 값이 비어 있기 때문에, codeHash와 마찬가지로 EOA는 비어있다.
위의 이더리움 계정 생성 과정에서 잠시 언급했듯이 하나의 계정은 한 쌍의 개인키,공개키로 식별되며 계정 주소는 공개키의 마지막 20바이트로 표시된다.
여기서 비밀키는 트랜잭션 '서명(sign)'에 이용되므로 아주 중요하다. 비밀키를 가지고 있어야만 서명을 할 수 있다. 반면 공개키는 말 그대로 누구에게나 공개해도 되는 키이며, 비밀키에 의해 생성된 서명을 공개키로 해독함으로써, 이 서명을 보낸 사람이 해당 비밀키를 보유하고 있음을 알 수 있다.
다시 말해, 누군가의 서명이 A의 공개키로 해독이 된다면, 이 것을 서명한 사람이 A라는 것을 증명할 수 있는 것이다. 반대로 A가 서명을 해서 보냈지만 A의 공개키로 해독이 되지 않는다면, 그것은 A가 아무리 자신이 보낸 것이라고 주장해도, A가 서명을 해서 보냈다고 받아들여지지 않는 것이다.
이더리움에서는 이 방식을 사용하여 트랜잭션에 서명한 사람이 실제 트랜잭션을 발생시킨 사람이 맞는지 확인하는 작업을 한다.
더 자세히 말하자면,
이더리움의 address는 signer의 public key로부터 얻어지고, 위에서 public key를 keccak256하여 해시화 한 수 맨 뒤 20bytes를 account address로 사용한다고 설명했다.
이더리움 프로토콜에서는 트랜잭션을 받으면 제일 먼저 해당 트랜잭션이 해당 account를 소유하고 있는 유저가 보낸 트랜잭션이 맞는지 검증을 하는 것이다.
그 트랜잭션 검증 절차가 끝나면 이더리움 프로토콜은 다음 스텝으로 트랜잭션을 실행하여 유저가 원하는 액션을 수행하고, 트랜잭션 실행에 사용된 가스비를 해당 계정에서 차감한다.
서명은 트랜잭션 전송 뿐만 아니라, 본인을 인증하기 위한 수단으로서 활용될 수 있다. 대표적으로 account의 식별에 활용될 수 있는데, opensea의 경우, 사이트에 접속하면, 현재 접속한 account를 식별하기 위해 지갑에 서명을 요청하게 된다. 이를 통해 로그인 절차 없이 본인임을 인증할 수 있는 것이다.
그렇다면 조금 더 자세히 어떻게 디지털 서명이 비밀키를 공개하지 않고 어떻게 비밀키의 소유권을 증명하는지 살펴보자.
이더리움에서 사용되는 디지털 서명 알고리즘은 ECDSA이다.
디지털 서명은 이더리움에서 세 가지 용도로 사용된다.
첫째, 서명은 이더리움 계정과 비밀키의 소유자가 이더 지출 또는 컨트랙트 이행을 승인했음을 증명한다.
둘째, 부인방지(non-repudiation)를 보장한다. 즉, 허가의 증거는 부인할 수 없다.
셋째, 서명은 트랜잭션이 서명된 후에는 트랜잭션 데이터가 수정되지 않았고 누구도 트랜잭션 데이터를 수정할 수 없음을 증명한다.
위키피디아의 디지털 서명 정의
디지털 서명은 디지털 메시지나 문서의 진위를 표현하기 위한 수학적 기법이다. 유효한 디지털 서명은 메시지가 알려진 발신자 (인증, Authentication)에 의해 생성되었고, 보낸 사람이 메시지를 보내지 않았음을 부인할 수 없으며 (부인방지, Non-repudiation), 메시지가 전송 준에 변경되지 않았다고 믿을 수 있는 근거를 제공한다.(무결성, Integrity)
유효한 트랜잭션을 생성하려면 발신자는 ECDSA를 사용하여 메시지에 디지털 서명을 해야한다.
'트랜잭션에 서명하시오'라는 말의 의미는 'RLP 시리얼라이즈된 트랜잭션 데이터의 Keccak-256 해시에 서명하시오'라는 뜻이다. 다시 말해, 서명은 트랜잭션 자체가 아니라 트랜잭션 데이터의 해시에 적용된다.
발신자는 이더리움에서 트랜잭션을 발생시키기 위해 반드시 다음의 과정을 거쳐야 한다.
1. nonce, gasPrice, gasLimit, to, value, data, chainId, 0, 0의 9개 필드를 포함하는 트랜잭션 데이터 구조를 만든다.
트랜잭션 데이터 구조를 '트랜잭션 객체'라고 하기도 하는데, 새로운 트랜잭션 타입이 생기면서 객체 구조가 조금 다양해 졌다. (대표적인 트랜잭션 타입 : Legacy Transaction, 1559 Transaction)
특수 서명 변수 v는 두 가지를 나타내는데, ECDSArecover 함수가 서명을 확인하는 데 도움이 되는 복구 식별자와 체인ID이다. v는 27 또는 28 중 하나로 계산되거나, 체인ID의 두배에 35 또는 36이 더해져 계산된다.
여기서는 트랜잭션을 생성하고 서명하기 위해 ethereumjs-ts
라이브러리를 사용한다.
import { Transaction } from "ethereumjs-tx";
...
signTx: function (web3: any, hash: any) {
const customCommon = common.getCustomCommon()
const decodedTx = ethers.utils.RLP.decode(hash);
const txObject = {
nonce: decodedTx[0],
gasPrice: decodedTx[1],
gasLimit: decodedTx[2],
to: decodedTx[3],
value: decodedTx[4],
input: decodedTx[5]
}
const tx = new Transaction(txObject, { common:customCommon });
const privateKeyStr = web3.eth.accounts.wallet[0].privateKey
const address = web3.eth.accounts.wallet[0].address
const privateKey = Buffer.from(
privateKeyStr.substring(2),
'hex'
);
tx.sign(privateKey);
const serializedTx = tx.serialize();
return {
hash:'0x' + serializedTx.toString("hex"), //rawTx
txObject: txObject,
address : address
}
},
sendSignedTx: async function(web3: any, raw: any) {
const txResult = await web3.eth.sendSignedTransaction(raw);
return {
contractAddress : txResult.contractAddress,
transactionHash : txResult.transactionHash
}
},
...
이 코드에서
const txObject = {
nonce: decodedTx[0],
gasPrice: decodedTx[1],
gasLimit: decodedTx[2],
to: decodedTx[3],
value: decodedTx[4],
input: decodedTx[5]
}
이런식으로 트랜잭션 객체를 생성하고, ethereumjs-tx라이브러리의 Transaction을 이용해서 우리가 알고 있는 완전한 트랜잭션 데이터 구조를 만든다.
//예시
* @example
* ```js
* const txData = {
* nonce: '0x00',
* gasPrice: '0x09184e72a000',
* gasLimit: '0x2710',
* to: '0x0000000000000000000000000000000000000000',
* value: '0x00',
* data: '0x7f7465737432000000000000000000000000000000000000000000000000000000600057',
* v: '0x1c',
* r: '0x5e1d3a76fbf824220eafc8c79ad578ad2b67d01b0c2425eb1f1347e8f50882ab',
* s: '0x5bd428537f05f9830e93792f90ea6a3e2d1ee84952dd96edbae9f658f831ab13'
* };
* const tx = new Transaction(txData);
* ```
*/
그 후에 privateKey를 통해 만들어진 원시 트랜잭션 tx에 서명작업을 한다.
tx.sign(privateKey);
const serializedTx = tx.serialize();
return {
hash:'0x' + serializedTx.toString("hex"), //rawTx
txObject: txObject,
address : address
}
...
...
sendSignedTx: async function(web3: any, raw: any) {
const txResult = await web3.eth.sendSignedTransaction(raw);
...
return 중 hash가 서명된 raw Transaction hash가 된다.
그리고 서명된 raw Tx를 send하면 트랜잭션이 전송되는 것이다.
서명하기 전에 트랜잭션 데이터 내부에 체인식별자를 포함하는 것은 재생 공격을 방지할 수 있도록 한다.
이렇게 하면 하나의 블록체인에 대해 생성된 트랜잭션이 다른 블록체인에서 유효하지 않게되기 때문에 다른 네트워크에서 재생될 수 없다.
결과로 생성되는 트랜잭션 구조는 RLP로 인코딩되고 해싱되고 서명된다.
서명 알고리즘은 v접두어에 체인 식별자를 인코딩하기 위해 약간 수정된다. (자세한건 EIP-155 참고)
이더리움 계정에서 시작해서 디지털 서명과 트랜잭션 서명 및 전송까지 쭉 살펴봤는데,
이것은 시작에 불과하다. 각 키워드 별로 넘쳐나는 자료와 응용편들을 보면 앞으로 얼마나 더 많은 공부가 필요한지 실감하게 된다.
일단 여기서 중요하게 다루고 싶었던 주제는 이더리움 계정이었지만, 더 넓고 방대하게 트랜잭션까지 살펴본 이유는 다음번 주제인 ERC-4337을 위한 빌드업이기때문이다.
조만간 ERC-4337에 대한 내용을 다룰 예정이다.
< 참고 >
- https://xangle.io/insight/research/64069b663b46fdc39f28d44c