티스토리 뷰
본 게시글에서는 저서 '밑바닥부터 시작하는 비트코인'의 Python으로 작성된 예제 코드를 Go로 컨버팅 하여 작성하였습니다.
📺 시리즈
2023.08.25 - [블록체인/비트코인] - 밑바닥부터 시작하는 비트코인 - 1장 유한체
2023.08.27 - [블록체인/비트코인] - 밑바닥부터 시작하는 비트코인 - 2장 타원곡선
2023.08.30 - [블록체인/비트코인] - 밑바닥부터 시작하는 비트코인 - 3장 타원곡선 암호
2023.09.02 - [블록체인/비트코인] - 밑바닥부터 시작하는 비트코인 - 4장 직렬화
2023.09.05 - [블록체인/비트코인] - 밑바닥부터 시작하는 비트코인 - 5장 트랜잭션
🐱👤 전체 코드
📄 스크립트란?
비트코인을 잠그고 해제하는 방법이 비트코인의 전송 메커니즘입니다. 스크립트는 비트코인의 전송 메커니즘을 프로그램으로 실행하기 위해 사용되는 간단한 프로그래밍 언어입니다. 앞서 트랜잭션 부분에서 다룬 트랜잭션 출력에 들어있는 잠금 스크립트를 사용해 누군가한테 비트코인을 줄 수 있고 트랜잭션 입력에 들어있는 해제 스크립트를 사용해 소유하고 있는 비트코인을 소비할 수 있습니다.
스크립트는 스택 기반의 언어로, 의도적으로 몇몇 기능이 배제되도록 설계되었습니다. 구체적으로는 무한루프를 사용한 서비스 거부 공격을 방지하기 위해 반복문을 사용할 수 없고, 따라서 스크립트는 튜링 불완전하다고 말합니다.
🏃♂️ 스크립트 실행
스크립트에서는 주어진 명령어를 한 번에 하나씩 스택 기반으로 처리합니다.
스크립트의 명령어에는 두 가지가 있습니다. 하나는 원소(element)이고 다른 하나는 연산자(operation)입니다.
원소는 스크립트 실행 명령어 집합 안에서 사용되는 데이터를 의미합니다. 대표적으로 DER 서명이나 SEC 공개키가 있습니다.
연산자는 데이터에 대해 무언가 동작을 실행합니다. 연산자의 예로 OP_DUP이 있습니다. OP_DUP은 스택 가장 위에 있는 원소를 복사하여 복사한 원소를 스택의 가장 위에 올려놓습니다. 즉, 원소를 복사하여 동일한 원소 2개가 스택 위에 올라가게 됩니다.
모든 명령어가 실행된 뒤에 스택에 0이 아닌 원소가 남아있어야 스크립트가 유효합니다. 스택이 비어있거나 0이 남아있다면 유효하지 않은 것으로 간주되어 해당 스크립트가 포함된 트랜잭션이 네트워크로 전파되지 않습니다.
➕ 주요 연산자
OP_HASH160은 스택 위의 원소 1개를 가져와서 hash160 함수를 적용한 해시값을 새로운 원소로 스택 위에 추가합니다.
OP_CHECKSIG는 스택 위 2개의 원소를 가져와서 첫 번째 원소는 공개키, 두 번째 원소는 서명으로 간주하여 서명을 공개키로 검증합니다. 만약 검증된다면 1을 스택 위로 올리고 그렇지 않으면 0을 올립니다.
연산자의 구현
func OpDup(s *[]any) bool {
if len(*s) < 1 {
return false
}
element := (*s)[len(*s)-1]
*s = append(*s, element)
return true
}
func OpHash160(s *[]any) bool {
if len(*s) < 1 {
return false
}
element := (*s)[len(*s)-1]
*s = (*s)[:len(*s)-1]
switch element := element.(type) {
case []byte:
hash := utils.Hash160(element)
*s = append(*s, hash)
return true
default:
return false
}
}
func OpHash256(s *[]any) bool {
if len(*s) < 1 {
return false
}
element := (*s)[len(*s)-1]
*s = (*s)[:len(*s)-1]
switch element := element.(type) {
case []byte:
hash := utils.Hash256(element)
*s = append(*s, hash)
return true
default:
return false
}
}
- 연산자는 int 타입, 원소는 바이트 슬라이스 타입이므로 하나의 슬라이스에 담기 위해 any 타입의 슬라이스를 사용했습니다.
- 함수의 파라미터로 받는 any 타입의 슬라이스는 연산 과정에서 실제로 수정이 필요하므로 슬라이스의 포인터를 받아오도록 설정했습니다.
- 함수 내부에서 switch 문을 사용하여 원소의 타입을 체크하고 바이트 슬라이스 타입이 아닌 경우에는 false를 반환했습니다.
📑 스크립트 파싱
잠금 스크립트(ScriptPubKey)와 해제 스크립트(ScriptSig) 모두 같은 방식으로 파싱 됩니다. 처음 읽은 한 바이트 값이 0x01~0x4b(1~75) 범위의 값이면 해당 길이만큼 읽어서 원소로 간주합니다. 원소 길이의 범위를 벗어난 경우(0 또는 76 이상)에는 연산자를 의미합니다. 비트코인에 정의된 전체 연산자는 https://en.bitcoin.it/wiki/Script에서 확인할 수 있습니다.
파싱 및 직렬화 함수 코딩하기
1. 파싱 함수
func Parse(b []byte) (*Script, int, error) {
length, read := utils.ReadVarint(b) // 가변 정수로된 스크립트의 전체 길이를 읽음
buf := bytes.NewBuffer(b[read:]) // 가변 정수를 제외한 나머지 스크립트를 버퍼에 저장
var cmds []any // 스크립트 명령어를 저장할 슬라이스
var count int // 읽은 바이트 수
// 읽어들인 바이트 수가 전체 길이보다 작은 동안 반복
for count < length {
current := buf.Next(1) // 버퍼에서 1바이트를 읽음
count += 1 // 읽은 바이트 수를 1 증가
currentByte := current[0]
if currentByte >= 1 && currentByte <= 75 { // 바이트 값이 1에서 75 사이인 경우: 해당 길이만큼 데이터를 읽어 원소로 추가
dataLength := int(currentByte)
data := buf.Next(dataLength)
count += dataLength
cmds = append(cmds, data)
} else if currentByte == 76 { // 바이트 값이 76인 경우: OP_PUSHDATA1에 해당하므로 다음 한 바이트를 더 읽어 해당 길이만큼 데이터를 읽어 원소로 추가
dataLength := utils.LittleEndianToInt(buf.Next(1))
data := buf.Next(dataLength)
cmds = append(cmds, data)
count += dataLength + 1
} else if currentByte == 77 { // 바이트 값이 77인 경우: OP_PUSHDATA2에 해당하므로 다음 두 바이트를 더 읽어 해당 길이만큼 데이터를 읽어 원소로 추가
dataLength := utils.LittleEndianToInt(buf.Next(2))
data := buf.Next(dataLength)
cmds = append(cmds, data)
count += dataLength + 2
} else { // 그 외의 경우: 해당 바이트 값을 연산자로 간주하여 추가
opCode := int(currentByte)
cmds = append(cmds, opCode)
}
}
// 읽은 바이트 수와 전체 길이가 일치하지 않는 경우 에러 반환
if count != length {
return nil, 0, errors.New("parse error: length mismatch")
}
// 스크립트와 읽은 바이트 수, 에러를 반환
return New(cmds...), read + length, nil
}
2. 직렬화 함수
func (s Script) RawSerialize() ([]byte, error) {
result := []byte{}
for _, cmd := range s.Cmds {
switch cmd := cmd.(type) {
// 스크립트 명령어가 []byte 타입인 경우: 원소의 길이에 따라 다른 방식으로 직렬화
case []byte:
length := len(cmd)
if length < 75 { // 원소의 길이가 75보다 작은 경우: 해당 길이를 1바이트 리틀엔디언으로 직렬화
result = append(result, utils.IntToLittleEndian(length, 1)...)
} else if length > 75 && length < 0x100 { // 원소의 길이가 75보다 크고 0x100보다 작은 경우: OP_PUSHDATA1에 해당하므로 76을 추가하고 길이를 1바이트 리틀엔디언으로 직렬화
result = append(result, 76)
result = append(result, utils.IntToLittleEndian(length, 1)...)
} else if length >= 0x100 && length < 520 { // 원소의 길이가 0x100보다 크거나 같고 520보다 작은 경우: OP_PUSHDATA2에 해당하므로 77을 추가하고 길이를 2바이트 리틀엔디언으로 직렬화
result = append(result, 77)
result = append(result, utils.IntToLittleEndian(length, 2)...)
} else { // 그 외의 경우: 에러 반환
return nil, errors.New("too long an cmd")
}
result = append(result, cmd...) // 직렬화한 데이터를 추가
// 스크립트 명령어가 int 타입인 경우: 연산자에 해당하므로 리틀엔디언으로 직렬화
case int:
result = append(result, utils.IntToLittleEndian(cmd, 1)...)
}
}
return result, nil
}
func (s Script) Serialize() ([]byte, error) {
result, err := s.RawSerialize() // 직렬화한 데이터
if err != nil {
return nil, err
}
total := len(result) // 직렬화한 데이터의 전체 길이
// 직렬화한 데이터의 전체 길이를 가변 정수로 직렬화한 뒤 직렬화한 데이터를 추가하여 반환
return append(utils.EncodeVarint(total), result...), nil
}
🔗 잠금/해제 스크립트 결합
스크립트를 실행하기 위해 잠금 스크립트와 해제 스크립트를 결합해야 하는데, 두 스크립트는 서로 다른 트랜잭션에 있습니다. 잠금 스크립트는 비트코인을 받았던 트랜잭션에서, 해제 스크립트는 비트코인을 소비하는 트랜잭션에서 가져옵니다. 해제 스크립트 명령어는 잠금 스크립트 명령어 위에 위치합니다.
결합 스크립트 코딩하기
func (s Script) Add(other *Script) *Script {
cmds := append(s.Cmds, other.Cmds...)
return New(cmds...)
}
📔 표준 스크립트와 비표준 스크립트
비트코인 스크립트는 다양한 조합으로 작성할 수도 있지만, 네트워크상의 여러 노드들이 공통으로 사용할 수 있는 표준 스크립트가 있습니다.
- p2pk: Pay-to-pubkey
- p2pkh: Pay-to-pubkey-hash
- p2sh: Pay-to-script-hash
- p2wpkh: Pay-to-witness-pubkey-hash
- p2wsh: Pay-to-witness-script-hash
이러한 스크립트에 사용되는 비트코인 주소도 일종의 스크립트 템플릿입니다. 지갑 소프트웨어는 다양한 종류의 주소 형식(p2pkh, p2sh, p2wpkh)을 인식하고 그에 맞는 잠금 스크립트를 생성합니다.
비표준 스크립트는 말 그대로 표준 스크립트가 아닌 형태로 여러 명령어들을 조합하여 잠금 스크립트와 해제 스크립트를 구성할 수 있음을 의미합니다.
📗 p2pk 스크립트
p2pk 스크립트는 최초의 스크립트 중 하나이며 비트코인 초기에 널리 사용되었습니다.
p2pk에서 비트코인은 공개키를 사용해 보냅니다. 비밀키의 소유자는 서명을 통해 비트코인을 해제하고 사용할 수 있습니다. 트랜잭션 출력의 잠금 스크립트는 비밀키의 소유자만 해당 출력을 해제하여 비트코인을 사용할 수 있도록 단단히 잠급니다.
p2pk에서 잠금 스크립트에 대응하는 해제 스크립트는 다음과 같이 해시 유형 바이트가 붙은 서명입니다.
p2pk 스크립트 실행 순서
1. 잠금 스크립트와 해제 스크립트를 결합하여 오른쪽의 명령어 집합을 만듭니다.
2. 명령어 집합에서 명령어를 하나씩 꺼내와서 원소면 스택에 올리고, 연산자면 대응하는 연산 로직을 실행합니다.
3. 먼저 서명(<signature>)을 꺼내오는데, 서명은 원소이므로 스택에 올려줍니다.
4. 공개키도 마찬가지로 원소이므로 스택에 올려줍니다.
5. OP_CHECKSIG 연산자는 스택의 가장 위에 있는 원소를 공개키로, 그다음 원소를 서명으로 가져와 파싱한 뒤 서명이 올바른지 검증합니다. 서명이 올바르면 스택에 1을 추가하고, 올바르지 않다면 0을 추가합니다.
모든 명령어가 실행된 뒤에 스택에 0이 아닌 원소가 남아있어야 스크립트가 유효하므로, 서명이 올바른 경우에는 스크립트가 유효하며 서명이 올바르지 않은 경우에는 스크립트가 유효하지 않습니다.
스크립트 실행 메서드 코딩하기
// 스크립트 명령어 집합을 순회하면서 스크립트가 유효한지 확인
func (s *Script) Evaluate(z []byte) bool {
cmds := s.Cmds // 스크립트 명령어 집합 복사
stack := []any{} // 스택
altstack := []any{} // 대체 스택
// 스크립트 명령어를 순회하면서 스택에 데이터를 추가하거나 연산을 수행
for len(cmds) > 0 {
cmd := cmds[0] // 스크립트 명령어 집합의 첫 번째 원소
cmds = cmds[1:] // 스크립트 명령어 집합의 첫 번째 원소 제거
switch cmd := cmd.(type) {
// 스크립트 명령어가 int 타입인 경우: 연산자에 해당하므로 연산을 수행
case int:
operation := OpCodeFuncs[OpCode(cmd)] // 연산자에 해당하는 함수 가져오기
if cmd > 98 && cmd < 101 {
fn, ok := operation.(func(*[]any, *[]any) bool) // 연산자에 해당하는 함수가 func(*[]any, *[]any) bool 타입인지 확인
if !ok {
return false
}
if !fn(&stack, &cmds) { // stack과 cmds를 인자로 연산자에 해당하는 함수 호출
return false
}
} else if cmd > 106 && cmd < 109 {
fn, ok := operation.(func(*[]any, *[]any) bool) // 연산자에 해당하는 함수가 func(*[]any, *[]any) bool 타입인지 확인
if !ok {
return false
}
if !fn(&stack, &altstack) { // stack과 altstack을 인자로 연산자에 해당하는 함수 호출
return false
}
} else if cmd > 171 && cmd < 176 {
fn, ok := operation.(func(*[]any, []byte) bool) // 연산자에 해당하는 함수가 func(*[]any, []byte) bool 타입인지 확인
if !ok {
return false
}
if !fn(&stack, z) { // stack과 z를 인자로 연산자에 해당하는 함수 호출
return false
}
} else {
fn, ok := operation.(func(*[]any) bool) // 연산자에 해당하는 함수가 func(*[]any) bool 타입인지 확인
if !ok {
return false
}
if !fn(&stack) { // stack을 인자로 연산자에 해당하는 함수 호출
return false
}
}
// 스크립트 명령어가 []byte 타입인 경우: 스택에 원소를 추가
case []byte:
stack = append(stack, cmd)
// 그 외의 경우: 에러 반환
default:
return false
}
}
// 스택이 비어있는 경우: 스크립트가 유효하지 않음
if len(stack) == 0 {
return false
}
switch popped := stack[len(stack)-1].(type) {
// 스택의 마지막 원소가 int 타입인 경우: 해당 원소가 0이 아닌 경우 스크립트가 유효함
case int:
if popped == 0 {
return false
}
// 스택의 마지막 원소가 []byte 타입인 경우: 해당 원소가 비어있지 않은 경우 스크립트가 유효함
case []byte:
if len(popped) == 0 {
return false
}
// 그 외의 경우: 에러 반환
default:
return false
}
return true
}
package main
import (
"chapter06/script"
"encoding/hex"
"fmt"
)
func main() {
// 서명해시
z, err := hex.DecodeString("7c076ff316692a3d7eb3c3bb0f8b1488cf72e1afcd929e29307032997a838a3d")
if err != nil {
panic(err)
}
// 공개키 sec 직렬화 값
sec, err := hex.DecodeString("04887387e452b8eacc4acfde10d9aaf7f6d9a0f975aabb10d006e4da568744d06c61de6d95231cd89026e286df3b6ae4a894a3378e393e93a0f45b666329a0ae34")
if err != nil {
panic(err)
}
// 서명 der 직렬화 값
sig, err := hex.DecodeString("3045022000eff69ef2b1bd93a66ed5219add4fb51e11a840f404876325a1e8ffe0529a2c022100c7207fee197d27c618aea621406f6bf5ef6fca38681d82b2f06fddbdce6feab6")
if err != nil {
panic(err)
}
scriptPubKey := script.New(sec, 0xac) // 공개키와 연산자 OP_CHECKSIG로 잠금 스크립트 생성
scriptSig := script.New(sig) // 서명으로 해제 스크립트 생성
combined := scriptSig.Add(scriptPubKey) // 잠금 스크립트와 해제 스크립트 결합
fmt.Println("valid?", combined.Evaluate(z)) // 결합한 스크립트를 평가한 결과 출력
}
$ go run main.go
valid? true
⭕ p2pk 스크립트의 문제점과 해결
p2pk 스크립트는 다음과 같은 문제점을 가지고 있습니다.
- 비압축/압축 SEC 형식을 사용함으로 인해 공개키의 길이가 너무 깁니다.
- 긴 공개키가 UTXO 집합에 포함되어 있으므로 공간과 인덱싱을 위한 계산 자원이 많이 필요합니다.
- 공개키를 누구나 볼 수 있기 때문에 혹여 ECDSA 암호가 깨질 경우 비트코인이 해킹될 수 있습니다.
p2pkh (Pay-to-pubkey-hash) 스크립트는 p2pk 스크립트의 문제점을 대비한 대안의 성격을 가지며 공개키에 hash160 함수를 적용한 해시값을 사용하여 트랜잭션 출력을 잠급니다.
📘 p2pkh 스크립트
p2pkh 스크립트는 다음과 같이 명령어 집합을 구성합니다.
p2pk 스크립트와 마찬가지로 명령어를 하나씩 꺼내와 스택에 추가합니다.
처음 두 개의 명령어는 원소이므로 스택에 추가해 줍니다.
그 다음은 OP_DUP 연산자이므로 스택의 가장 위에 있는 원소를 복사하여 스택에 추가합니다.
OP_HASH160은 스택의 가장 위에 있는 원소를 가져와서 hash160 함수를 적용한 뒤에 결과인 해시값을 스택에 추가합니다.
20바이트 해시값은 원소이므로 스택 위에 추가합니다.
OP_EQUALVERIFY는 스택 위에 있는 2개의 원소를 가져와 동일한 원소인지 검사합니다. 같은 경우에는 스크립트 실행이 이어지지만, 다를 경우에는 검증 실패로 끝나게 됩니다. 여기서는 동일한 경우를 살펴보겠습니다.
OP_CHECKSIG는 p2pk 스크립트와 동일하게 동작합니다. 공개키와 서명을 스택에서 꺼내와 만약 서명이 유효하다면 스택에 1을 추가하고 올바르지 않다면 0을 추가합니다.
p2pkh 스크립트는 실패지점이 두 곳이 있습니다. 하나는 잠금 스크립트에 들어있는 해시값과 해제 스크립트에 들어있는 공개키를 hash160 함수로 해싱한 값을 비교하는 지점, 그리고 다른 하나는 서명이 유효한지 검사하는 지점입니다.
p2pkh 스크립트는 잠금 스크립트가 25바이트(해시 길이 1바이트 + 해시값 20바이트 + 연산자 4바이트)로 작다는 장점이 있습니다.
💯 문제해결
1. 바이트 슬라이스를 뒤집는 과정에서 길이가 홀수인 경우에 잘못된 결과가 반환되는 문제
// 기존 코드
func ReverseBytes(b []byte) []byte {
result := make([]byte, len(b))
for i, j := 0, len(b)-1; i < j; i, j = i+1, j-1 {
result[i], result[j] = b[j], b[i]
}
return result
}
// 수정한 코드
func ReverseBytes(b []byte) []byte {
n := len(b)
for i, j := 0, n-1; i < j; i, j = i+1, j-1 {
b[i], b[j] = b[j], b[i] // 바이트 슬라이스의 앞뒤를 서로 바꿈
}
return b
}
새로운 바이트 슬라이스를 만들어서 값을 채우는 대신, 파라미터로 받은 바이트 슬라이스를 뒤집어서 반환하는 방식으로 변경했습니다.
2. OP_CODE와 관련된 코드를 Python에서 Go로 옮기기
이 부분은 문제해결이라기보다는 노가다 작업으로 인해 조금 고달팠다는 것을 호소하고자 추가하였습니다... 잘 돌아가는지 테스트를 해보고 싶은데 어디서부터 손을 대야 할지 테스트 코드는 어떻게 작성을 해야 하는지 조금 어려움이 있는 것 같습니다. https://github.com/jimmysong/programmingbitcoin/blob/master/code-ch06/op.py 참고한 코드 링크입니다.
3. 잘못된 예제?
'스크립트 실행 메서드 코딩하기' 부분에서 원래 주어진 서명값은 다음과 같습니다. 바이트 스트림으로 변환했을 때 전체 길이가 71인 16 진수값입니다.
3045022000eff69ef2b1bd93a66ed5219add4fb51e11a840f404876325a1e8ffe0529a2c022100c7207fee197d27c618aea621406f6bf5ef6fca38681d82b2f06fddbdce6feab601
그런데 이 값을 파싱하기 위해 쪼개보면 이상한 점이 있습니다.
compound - 0x30
total length - 0x45
marker - 0x02
length of r - 0x20
r - 0x00eff69ef2b1bd93a66ed5219add4fb51e11a840f404876325a1e8ffe0529a2c
marker - 0x02
length of s - 0x21
s - 0x00c7207fee197d27c618aea621406f6bf5ef6fca38681d82b2f06fddbdce6feab601
s의 길이가 10진수 표현으로 33이어야 하는데 정작 남은 s의 바이트 수는 34로, 뒤에 의문의 '0x01'이 붙어있는 것입니다. 전체 길이를 봐도 분명 69에 compound를 합치면 서명의 총길이가 70이어야 하는데 주어진 서명값은 길이가 71로 주어져 있습니다. 그래서 그런지 주어진 서명을 그대로 사용하면 파싱하는 부분에서 오류가 발생을 하더군요.
뒤에 붙어있는 '01'을 지워줌으로써 파싱하고 스크립트를 검증하는 과정을 모두 정상적으로 실행할 수 있었습니다. 이거 예제가 잘못된 거 맞죠? 그렇죠? 파이썬에서는 어떻게 돌아가길래 이슈 탭에 관련 내용이 없는 걸까...
[2023.09.16 추가]
뒤에 붙어있는 0x01은 해시 유형이었습니다. 그중에서도 SIGHASH_ALL이었네요. 해시 유형을 다시 붙이면 서명을 파싱하는 함수에서 다시 문제가 발생합니다. 이 부분은 7장에서 해결해 보겠습니다.
4. unsafe 패키지를 사용하여 string을 []byte로 변환할 때 주의할 점
이 문제 때문에 몇 날며칠을 시름했는데 드디어 해결했습니다. 트랜잭션의 ID를 출력하기 위해 트랜잭션을 직렬화할 때마다 트랜잭션 입력의 이전 트랜잭션 ID가 뒤집히는 문제였습니다. 문제의 원인은 문자열을 바이트 슬라이스로 변환하기 위해 사용한 유틸리티 함수에 있었습니다.
// 문자열을 바이트 슬라이스로 변환하는 함수
func StringToBytes(s string) []byte {
return unsafe.Slice(unsafe.StringData(s), len(s))
}
// 바이트 슬라이스를 문자열로 변환하는 함수
func BytesToString(b []byte) string {
return unsafe.String(unsafe.SliceData(b), len(b))
}
기존에는 이렇게 unsafe 패키지를 사용해서 문자열을 바이트 슬라이스로 변환하거나 바이트 슬라이스를 문자열로 변환했는데, 이 방법은 값을 복사하지 않고 원본 데이터 자체를 다른 타입으로 변환하기 위해 사용할 수 있습니다. https://betterprogramming.pub/6-ways-to-boost-the-performance-of-your-go-applications-5382bb7532d7 참고한 자료는 이것인데 본문에도 값을 복사하지 않고 변환한다고 나와있습니다. 그렇기 때문에 문자열에서 변환된 바이트 슬라이스를 뒤집게 되면 원본 문자열까지 뒤집히는 불상사가 발생하는 것입니다. 이 함수들은 다른 곳에서도 사용을 하고 있으므로 일단 다음과 같이 변경했습니다.
// 문자열을 바이트 슬라이스로 변환하는 함수
func StringToBytes(s string) []byte {
return []byte(s)
}
// 바이트 슬라이스를 문자열로 변환하는 함수
func BytesToString(b []byte) string {
return string(b)
}
📖 참고자료
글에서 수정이 필요한 부분이나 설명이 부족한 부분이 있다면 댓글로 남겨주세요!
'블록체인 > Bitcoin' 카테고리의 다른 글
밑바닥부터 시작하는 비트코인 - 8장 p2sh 스크립트 (0) | 2023.09.20 |
---|---|
밑바닥부터 시작하는 비트코인 - 7장 트랜잭션 검증과 생성 (1) | 2023.09.16 |
밑바닥부터 시작하는 비트코인 - 5장 트랜잭션 (0) | 2023.09.05 |
밑바닥부터 시작하는 비트코인 - 4장 직렬화 (0) | 2023.09.02 |
밑바닥부터 시작하는 비트코인 - 3장 타원곡선 암호 (0) | 2023.08.30 |