2️⃣

스크립트

스마트 계약

스마트 계약이란 블록체인상 코인의 전송을 프로그램으로 기술하는 것을 어렵게 쓴 말이다.
비트코인 스크립트는 비트코인의 스마트 계약 언어로서 비트코인이 소비되는 조건을 기술하는 프로그래밍 언어이다.
비트코인 스크립트는 포스(Forth)라는 언어와 유사한 역폴란드 표기법의 스택 기반의 언어이고, 기능이 제한적이다.
역폴란드 표기법(Reverse Polish Notation)
연산자를 피연산자 뒤에 쓰는 연산 표기법으로 후위 표기법(Postfix Notation)이라고도 한다.
우리에게 친숙한 중위 표기법 3 + 5 x 2를 역폴란드 표기법으로 바꾸면 3 5 2 x + 이다.
이러한 수식은 스택에 피연산자를 넣고(PUSH), 연산자를 만나면 피연산자 두 개를 꺼내서(POP) 계산 후 결과값을 스택에 반환(PUSH)한다.
반복작업을 위한 루프 기능이 없어 튜링 불완전하다고 말할 수 있다.
비트코인이 튜링 불완전한 이유
튜링 완전하다는 의미는 복잡한 연산이 가능하고 반복작업을 위한 루프 기능이 있다는 의미이다.
모든 노드에서 스크립트 프로그램을 실행하기 때문에 단순하고 빠른 처리가 필요하다.
스마트 계약이 복잡하면 의도치 않은 오류나 버그를 발생할 수 있다. 블록체인에서는 이를 돌이킬 수 없기 때문에 작은 오류나 버그도 매우 치명적이다.
반복문은 의도치 않은 또는 의도적 서비스 거부공격(DoS)을 위해 무한루프를 작성하는 경우 전체 네트워크의 마비가 올 수 있다.
비트코인의 가장 기본적인 스크립트는 입력에서 디지털 서명을 통해 자신의 비트코인임을 검증하는 해제 스크립트와 받는 사람의 서명을 요청하는 잠금스크립트가 대표적이다.

스크립트의 실행

스크립트는 프로그래밍 언어이다.
스크립트는 다른 프로그래밍 언어와 유사하게 연산자와 원소로 이루어진다.
원소는 실행을 위한 데이터를 의미한다.
예를들어 서명 값, 공개키 등은 원소에 해당한다.
연산자는 데이터를 통해 무언가를 하는 함수이다.
예를들어 서명 검증, 해시 알고리즘 실행 등이 있다.
모든 원소와 연산자는 16진수로 표현되는 OP코드로 대응되어 프로그래밍 된다. 다음은 OP코드의 예시이다.
0x01~0x4b : 이 이후에 오는 1~75(0x4b)바이트는 원소이다. (1~75바이트 길이 원소 표현)
0x4c (OP_PUSHDATA1) : 다음 한 바이트는 원소의 갯수이고 그 이후는 원소이다. (76~255바이트 길이 원소 표현)
0x4d (OP_PUSHDATA2) : 다음 두 바이트는 원소의 길이이고 그 이후는 원소이다. (256~520바이트 길이 원소 표현)
0x4e (OP_PUSHDATA4) : 다음 네 바이트는 원소의 길이이고 그 이후는 원소이다. 네트워크 허용 길이를 넘기 때문에 실질적으로 미사용된다.
0x76 (OP_DUP) : 가장 위에 있는 원소를 하나 복사하여 스택에 넣는다.
0x94 (OP_ADD) : 가장 위에 있는 두 개의 원소를 더하여 스택에 넣는다.
0xa9 (OP_HASH160) : 가장 위에 있는 원소를 2회 해시한다. (SHA-256, RIPEMD-160)
모든 종류의 OP코드를 보고 싶다면 아래의 페이지에서 확인하길 바란다.
OP 코드 적용의 예시 : 지난번에 보았던 출력의 스크립트를 해석해보자
아래 직렬화 트랜잭션에서 76 a9 14 ab68025513c3dbd2f7b92a94e0581f5d50f654e7 88 ac 부분이 스크립트였다.
76 : OP_DUP
a9 : OP_HASH160.
14 : 다음 20 바이트(16진수로 14)는 데이터임을 의미한다.
ab68025513c3dbd2f7b92a94e0581f5d50f654e7 : 총 20 바이트의 원소영역
88 : OP_EQUALVERIFY
ac : OP_CHECKSIG
스크립트는 왼쪽에서 한 바이트씩 스택에 넣어가며 연산을 수행한다.
스크립트 예시를 통해 이해해보자.
위 그림은 간단한 스크립트 연산을 보여준다.
실행 포인터(Execution pointer)가 좌측에서 우측으로 이동하며 원소와 연산자를 왼쪽에 있는 스택에 넣는다.
2를 스택에 넣는다.
3을 스택에 넣는다.
OP_ADD는 스택에서 원소 2개를 꺼내어 더한 뒤 결과를 스택에 넣는다. 5를 넣는다.
5를 스택에 넣는다. (5가 두개가 됐다.)
OP_EQUAL은 스택에서 원소 2개를 꺼내어 같으면 TRUE, 다르면 FALSE를 넣는다. TRUE를 넣었다.

잠금 스크립트와 해제 스크립트

트랜잭션의 입력과 출력에 각각 스크립트가 들어있다.
위 그림은 트랜잭션 입력 부분에 있는 해제 스크립트(Unlocking Script)와 출력 부분에 있는 잠금 스크립트(Locking Script)를 나타낸다.
해제 스크립트는 주로 디지털 서명을 검증하는 역할을 하므로 scriptSig라고 한다.
잠금 스크립트는 비트코인을 받을 대상자의 공개키를 지정하므로 scriptPibKey라고 한다.
트랜잭션에서의 스크립트 실행은 다음과 같은 과정을 따른다.
먼저 해제 스크립트가 실행된다.
해제 스크립트가 오류없이 실행되면 잠금 스크립트가 실행된다.
잠금 스크립트의 실행 결과가 TRUE(or 1)가 되면 UTXO가 실행된다.
TRUE 이 외에 다른 결과가 남아있으면 지출 요건이 실행되지 못한 것이므로 무효가 된다.

서명의 직렬화

해제 스크립트에는 서명값이 포함된다.
다음은 입력 스크립트의 예시이다.
이를 해석해보자.
30: 시작 인덱스
45: 열의 총 길이 (69 bytes)
02: 이어 나오는 R값이 정수형임을 나타냄.
21: R의 길이를 의미함. (16진수 21은 십진수로 33. 바이트 단위로 33 bytes)
00884d142d86652a3f47ba4746ec719bbfbd040a570b1deccbb6498c75c4ae24cb: r값
02: 이어 나오는 S값이 정수형임을 나타냄.
20: S의 길이를 의미함. (16진수 20은 십진수로 32. 바이트 단위로 32 bytes)
4b9f039ff08df09cbe9f6addac960298cad530a863ea8f53982c09db8f6e3813: s값
01: 서명해시 유형 (여기서는 SIGHASH_ALL이 적용되어 있음.)
서명 검증에는 r, s ,z가 필요한데 z는 메시지의 해시값이다.
마지막 바이트인 서명해시 유형(SIGHASH)에 따라 어떤 메시지의 해시값인지 결정된다.

P2PK (Pay-to-PubKey) 스크립트

비트코인 초기에 널리 사용된 스크립트로 공개키에 비트코인을 지불한다는 의미이다.
다음은 P2PK 잠금 스크립트의 예시이다.
공개키와 연산자 OP_CHECKSIG가 존재하는 것을 알 수 있다.
다음은 P2PK 해제 스크립트의 예시이다.
서명값이 들어있는것을 볼 수 있다.
해제 스크립트와 잠금 스크립트를 순서대로 처리하면 다음과 같다.
1.
먼저 잠금 스크립트를 실행하고 이후에 해제 스크립트를 실행하기 위해 스택을 구성한다. (오른쪽)
2.
현재 스택은 비어있다.
3.
가장 위에 있는 서명값<signature>가 스택에 담긴다.
4.
다음 공개키<pubkey>가 스택에 담긴다.
5.
OP_CHECKSIG 연산자는 두 개의 원소를 스택에서 꺼내 서명을 검증하는 연산자이다. 서명이 맞으면 1, 틀리면 0을 스택에 넣는다.

P2PKH(Pay-to-Public Key Hash)

P2PK는 다소 문제점이 있다.
공개키의 길이가 너무 길다. 압축형 공개키의 경우 33바이트나 된다.
너무 길어서 인코딩에서 짤리는 경우가 많다.
공개키를 주소로 적기에 너무 길다.
UTXO 집합에 보관하기에 풀노드에게 부담이 된다.
훗날 ECDSA가 깨지면 개인키 노출의 위험이 있다.
P2PKH는 공개키의 해시에 지불하는 것을 말한다.
P2PKH는 P2PK의 문제를 해결한다.
공개키가 아닌 공개키의 해시값을 사용하여 더 짧다.
추가 해시를 적용하여 보안적으로 더 안전하다.
공개키를 잠금 스크립트가 아닌 해제 스크립트에 포함한다.
미지출된 출력 UTXO에서는 공개키가 없어서 풀노드에 부담이 적다.
지불하기 전의 UTXO는 공개키를 알 수 없기 때문에 이 UTXO를 해킹하려면 이산로그 문제를 두 번 풀어야 한다.
UTXO가 지불되면 공개키가 노출되지만, 이미 지불이 마친 출력이기 때문에 상관없다.
한번 노출된 공개키는 다시 사용하지 않는것이 안전하다.
다음은 P2PKH 잠금 스크립트의 예시이다.
공개키 대신에 공개키의 해시값이 들어간다.
다음은 P2PKH 해제 스크립트의 예시이다.
서명값과 공개키가 포함되어 있다.
P2PKH 스크립트를 따라가보자.
1.
잠금 스크립트와 해제 스크립트를 합친다.
2.
현재 스택은 비어있다.
3.
스택에 두 원소 공개키<pubkey>와 서명값<signature>를 올린다.
4.
OP_DUP는 스택에서 원소를 하나 꺼내 복사하여 다시 집어 넣는 연산자이다. 공개키를 복사한다.
5.
OP_HASH160은 스택에서 원소를 하나 꺼내서 SHA-160(SHA-256 해시값의 RIPEMD-160 해시값)해시코드<hash>를 스택에 다시 넣는다.
6.
스택에 공개키의 해시값<hash>을 넣는다.
7.
OP_EQUALVERIFY는 스택에서 2개의 원소를 가져와서 같은 원소인지 검사한다. 검증에 실패하면 스크립트 검증은 실패로 끝난다.
8.
서명을 검증한다. 검증에 성공하면 1을 스택에 남긴다.

비표준 스크립트

출력 스크립트에 꼭 서명 검증을 넣을 필요는 없다.
다음과 같은 잠금 스크립트를 쓰면 매우 간단한 퀴즈에 상금을 부여할 수 있다.
잠금스크립트
해제 스크립
잠금 스크립트는 5에 무엇을 더하면 9와 같은지를 정의하고 있고, 해제 스크립트는 4를 정의하고 있다.

구현 : Script

#P2PKH 스크립트 정의 def p2pkh_script(h160): return Script([0x76, 0xa9, h160, 0x88, 0xac] class Script: def __init__(self, cmds=None): if cmds is None: self.cmds = [] else: self.cmds = cmds def __repr__(self): result = [] for cmd in self.cmds: if type(cmd) == int: if OP_CODE_NAMES.get(cmd): name = OP_CODE_NAMES.get(cmd) else: name = 'OP_[{}]'.format(cmd) result.append(name) else: result.append(cmd.hex()) return ' '.join(result) def __add__(self, other): return Script(self.cmds + other.cmds) #스크립트를 원소와 연산자로 나누어 배열에 넣는 함수 @classmethod def parse(cls, s): #스크립트의 길이 length = read_varint(s) #명령어가 저장될 배열 cmds = [] count = 0 while count < length: #1바이트를 읽는다. current = s.read(1) count += 1 #바이트를 10진수로 변경 current_byte = current[0] #만약 1에서 75사이라면 if current_byte >= 1 and current_byte <= 75: #원소를 저장한다. n = current_byte #커멘드에 저장한다 cmds.append(s.read(n)) count += n #76이면 elif current_byte == 76: #op_pushdata1 이다. data_length = little_endian_to_int(s.read(1)) #데이터 길이만큼 커멘드에 저장한다. cmds.append(s.read(data_length)) count += data_length + 1 elif current_byte == 77: # op_pushdata2 이다. data_length = little_endian_to_int(s.read(2)) cmds.append(s.read(data_length)) count += data_length + 2 else: #나머지는 연산자이다. op_code = current_byte #op code를 그대로 커멘드에넣는다. cmds.append(op_code) if count != length: raise SyntaxError('parsing script failed') return cls(cmds) #스크립트 길이를 포함하지 않는 직렬화 def raw_serialize(self): # initialize what we'll send back result = b'' #커멘드를 돌면서 명령어를 넣는다. for cmd in self.cmds: #원소가 만약 숫자이면 연산자이다. if type(cmd) == int: #그대로 붙인다. result += int_to_little_endian(cmd, 1) else: #아니면 원소이다. #길이를 구해서 length = len(cmd) #각 원소의 길이에 따라 코드를 삽입한다. if length < 75: result += int_to_little_endian(length, 1) elif length > 75 and length < 0x100: # pushdata1 result += int_to_little_endian(76, 1) result += int_to_little_endian(length, 1) elif length >= 0x100 and length <= 520: # pushdata2 result += int_to_little_endian(77, 1) result += int_to_little_endian(length, 2) else: raise ValueError('too long an cmd') result += cmd return result #스크립트 길이를 포함하는 직렬화 def serialize(self): #직렬화 데이터를 가져와서 result = self.raw_serialize() total = len(result) #스크립트 길이를 붙이고 리턴한다. return encode_varint(total) + result #스크립트를 검증하는 함수 #op코드 정의와 연산은 다음 소스코드를 참고하기 바란다. #https://github.com/jimmysong/programmingbitcoin/blob/master/code-ch07/op.py def evaluate(self, z): #커멘드를 별도의 공간에 담는다. cmds = self.cmds[:] stack = [] altstack = [] #cmds 변수가 빌 때 까지 while len(cmds) > 0: #일단 하나를 먼저 꺼낸다. cmd = cmds.pop(0) #연산자이면 if type(cmd) == int: #매핑 함수를 가져온다. operation = OP_CODE_FUNCTIONS[cmd] #op_if, op_notif 인 경우 if cmd in (99, 100): #op_if/op_notif는 cmd 배열의 조작을 필요로 한다. if not operation(stack, cmds): LOGGER.info('bad op: {}'.format(OP_CODE_NAMES[cmd])) return False elif cmd in (107, 108): # op_toaltstack/op_fromaltstack은 다른 스택을 사용한다. if not operation(stack, altstack): LOGGER.info('bad op: {}'.format(OP_CODE_NAMES[cmd])) return False elif cmd in (172, 173, 174, 175): #모두가 서명에 관련된 연산자이다. 서명 해시 z를 필요로 한다. if not operation(stack, z): LOGGER.info('bad op: {}'.format(OP_CODE_NAMES[cmd])) return False else: if not operation(stack): LOGGER.info('bad op: {}'.format(OP_CODE_NAMES[cmd])) return False else: stack.append(cmd) if len(stack) == 0: return False if stack.pop() == b'': return False return True
Python
복사

참고문헌