티스토리 뷰
본 게시글에서는 저서 '밑바닥부터 시작하는 비트코인'의 Python으로 작성된 예제 코드를 Go로 컨버팅 하여 작성하였습니다.
📺 시리즈
2023.08.25 - [블록체인/비트코인] - 밑바닥부터 시작하는 비트코인 - 1장 유한체
2023.08.27 - [블록체인/비트코인] - 밑바닥부터 시작하는 비트코인 - 2장 타원곡선
2023.08.30 - [블록체인/비트코인] - 밑바닥부터 시작하는 비트코인 - 3장 타원곡선 암호
2023.09.02 - [블록체인/비트코인] - 밑바닥부터 시작하는 비트코인 - 4장 직렬화
2023.09.05 - [블록체인/비트코인] - 밑바닥부터 시작하는 비트코인 - 5장 트랜잭션
2023.09.11 - [블록체인/비트코인] - 밑바닥부터 시작하는 비트코인 - 6장 스크립트
2023.09.16 - [블록체인/비트코인] - 밑바닥부터 시작하는 비트코인 - 7장 트랜잭션 검증과 생성
2023.09.17 - [블록체인/비트코인] - 밑바닥부터 시작하는 비트코인 - 중간정리
🐱👤 전체 코드
🥅 학습 목표
1. 다중서명이 무엇이고 어떻게 구성되는지 알아봅니다.
2. 다중서명의 문제점과 이를 해결하기 위한 방법인 p2sh 스크립트에 대해 알아봅니다.
3. p2sh 스크립트를 검증하는 코드를 작성합니다.
📝 1. 다중서명
비밀키를 하나만 사용할 경우, 비밀키를 분실하거나 도난당하게 되면 모든 자금을 잃게 됩니다. 이를 방지하기 위해 여러 개의 비밀키를 사용하는 다중서명(multisig)을 사용합니다. 다중서명은 비트코인의 스크립트를 이용하여 구현할 수 있으며, 그 중에서 p2sh(pay-to-script-hash)를 사용하는 방법을 알아봅니다.
다중서명을 지원하도록 잠금 스크립트를 구성하는 첫 번째 시도로 베어 다중서명(bare multisig)이 있습니다. 베어 다중서명은 잠금 스크립트에 여러 개의 공개키가 들어가며, 공개키가 그대로 노출되기 때문에 베어(bare)라고 부릅니다.
OP_CHECKMULTISIG 명령어는 m과 n, m개의 서명, 그리고 n개의 공개키 즉 n + m + 2개의 원소를 가져와야 합니다. 그런데 OP_CHECKMULTISIG 명령어는 1개의 원소를 더 가져옵니다. 추가로 가져온 원소로는 어떠한 동작도 하지 않습니다. 아무것도 하지 않지만 원소의 개수가 모자라면 명령어가 실패하므로 임의의 원소를 하나 추가해줘야 합니다. 이 때 트랜잭션 가변성 문제로 인해 네트워크상 대부분 노드가 마지막 원소가 OP_0이 아니면 트랜잭션을 전파하지 않으므로 마지막 원소는 가능하면 OP_0으로 채워주는 것이 좋습니다. OP_CHECKMULTISIG 명령어의 Off-by-one 버그는 비트코인의 초기 버전부터 존재한 버그로, 이 버그를 수정하면 비트코인의 하드포크가 발생합니다. 따라서 이 버그는 영원히 남아있을 것입니다.
📌 2. OP_CHECKMULTISIG 함수
func OpCheckMultiSig(s *[]any, z []byte) bool {
if len(*s) < 1 {
return false
}
encN, ok := (*s)[len(*s)-1].([]byte) // 인코딩된 n
if !ok {
return false
}
*s = (*s)[:len(*s)-1]
n := DecodeNum(encN) // n
if len(*s) < n+1 {
return false
}
pubKeys := make([][]byte, n) // pubkeys
for i := 0; i < n; i++ {
pubKey, ok := (*s)[len(*s)-1].([]byte)
if !ok {
return false
}
*s = (*s)[:len(*s)-1]
pubKeys[i] = pubKey
}
encM, ok := (*s)[len(*s)-1].([]byte) // 인코딩된 m
if !ok {
return false
}
*s = (*s)[:len(*s)-1]
m := DecodeNum(encM) // m
if len(*s) < m+1 {
return false
}
derSigs := make([][]byte, m) // der sigs
for i := 0; i < m; i++ {
derSig, ok := (*s)[len(*s)-1].([]byte)
if !ok {
return false
}
*s = (*s)[:len(*s)-1]
derSigs[i] = derSig[:len(derSig)-1] // remove the sighash type
}
*s = (*s)[:len(*s)-1] // pop off the 0
points := make([]ecc.Point, n) // points
sigs := make([]ecc.Signature, m) // sigs
for i := 0; i < n; i++ {
point, err := ecc.ParsePoint(pubKeys[i])
if err != nil {
log.Println("line 1538:", err)
return false
}
points[i] = point
}
for i := 0; i < m; i++ {
sig, err := ecc.ParseSignature(derSigs[i])
if err != nil {
log.Println("line 1547:", err)
return false
}
sigs[i] = sig
}
// check that all the signatures are valid
for _, sig := range sigs {
for len(points) > 0 {
point := points[0]
points = points[1:]
ok, err := point.Verify(z, sig)
if err != nil {
log.Println("line 1561:", err)
return false
}
if ok {
break
}
}
}
*s = append(*s, EncodeNum(1))
return true
}
- n을 가져옵니다. n은 공개키의 개수입니다.
- n개의 공개키를 가져옵니다.
- m을 가져옵니다. m은 서명의 개수입니다.
- m개의 서명을 가져옵니다.
- off-by-on 버그를 처리하기 위해 스택에서 원소를 하나 더 가져옵니다.
- 공개키와 서명을 파싱합니다.
- 모든 서명이 유효한지 확인합니다.
- 모든 서명이 유효하면 1을 스택에 넣습니다.
💢 3. 다중서명의 문제점
📒 4. p2sh 스크립트
마지막으로 OP_EQUAL 명령어는 스택 위에 있는 두 개의 원소가 같으면 1을, 그렇지 않으면 0을 반환합니다.
💻 5. p2sh 스크립트 코딩하기
Script 구조체의 Verify 메서드 수정
case []byte:
stack = append(stack, cmd)
// cmds 안에 3개의 명령어가 남아있고 BIP0016에서 규정한 특별 패턴에 해당하는 경우
if len(cmds) == 3 {
// cmds의 첫 번째 원소가 OP_HASH160, cmds의 두 번째 원소가 20바이트인 []byte 타입, cmds의 세 번째 원소가 OP_EQUAL인지 확인
opCodeH160, ok1 := cmds[0].(int)
h160, ok2 := cmds[1].([]byte)
opCodeEqual, ok3 := cmds[2].(int)
// 특별 패턴에 해당한다면 리딤 스크립트의 해시값을 생성하여 비교(OP_EQUAL)하고
// 동일할 경우, 리딤 스크립트를 파싱하여 명령집합에 추가
if ok1 && ok2 && ok3 && opCodeH160 == 0xa9 && len(h160) == 20 && opCodeEqual == 0x87 {
cmds = cmds[3:] // cmds에서 3개의 명령어 제거
if !OpHash160(&stack) {
return false, errors.New("failed to evaluate OP_HASH160")
}
stack = append(stack, h160)
if !OpEqual(&stack) {
return false, errors.New("failed to evaluate OP_EQUAL")
}
if !OpVerify(&stack) {
return false, errors.New("failed to evaluate OP_VERIFY")
}
redeemScript := append(utils.EncodeVarint(len(cmd)), cmd...)
script, _, err := Parse(redeemScript) // redeemScript 파싱
if err != nil {
return false, err
}
cmds = append(cmds, redeemScript.Cmds...) // cmds에 스크립트 명령어 집합 추가
}
}
- cmds 안의 3개의 명령어가 BIP0016에서 규정한 특별 패턴에 해당하는지 확인합니다.
- 첫 번째는 OP_HASH160 연산으로 스택에 들어있는 리딤 스크립트를 꺼내 해싱한 값을 스택에 추가합니다.
- 두 번째는 20바이트 해시값이므로 스택에 추가합니다.
- 세 번째는 OP_EQUAL 연산으로 스택에 들어있는 두 개의 해시값을 비교하여 동일한 경우 1을 스택에 추가합니다.
- 0 아닌 값이 스택에 들어있는지 확인하기 위해 OP_VERIFY 연산을 실행합니다.
- 스크립트를 파싱하기 위해서 길이가 필요하므로 리딤 스크립트를 파싱하기에 앞서 리딤 스크립트 길이를 구하고 앞에 붙여줍니다.
- 리딤 스크립트를 파싱하여 명령집합에 추가합니다.
5.1 다중서명 이외의 p2sh
5.2 p2sh 주소
5.3 p2sh 서명 검증
1단계: 모든 해제 스크립트를 지운다
2단계: 삭제된 해제 스크립트 자리에 리딤 스크립트를 삽입한다
3단계: 해시 유형을 덧붙인다
5.4 p2sh 서명 검증 코딩하기
Tx 구조체의 SigHash 메서드 수정
// 트랜잭션의 서명해시를 반환하는 함수
// inputIndex는 서명해시를 만들 때 사용할 입력의 인덱스
// redeemScripts는 리딤 스크립트 목록
func (t Tx) SigHash(inputIndex int, redeemScripts ...*script.Script) ([]byte, error) {
// 입력 인덱스가 트랜잭션의 입력 개수보다 크면 에러를 반환
if inputIndex >= len(t.Inputs) {
return nil, fmt.Errorf("input index %d greater than the number of inputs %d", inputIndex, len(t.Inputs))
}
s := utils.IntToLittleEndian(t.Version, 4) // 버전
in, err := t.serializeInputsForSig(inputIndex, redeemScripts...) // 입력 목록, 입력의 인덱스와 리딤 스크립트 목록을 사용
if err != nil {
return nil, err
}
s = append(s, in...)
out, err := t.serializeOutputs() // 출력 목록
if err != nil {
return nil, err
}
s = append(s, out...)
s = append(s, utils.IntToLittleEndian(t.Locktime, 4)...) // 유효 시점
s = append(s, utils.IntToLittleEndian(SIGHASH_ALL, 4)...) // SIGHASH_ALL (4바이트)
h256 := utils.Hash256(s) // 해시를 생성
return h256, nil // 해시를 반환
}
// 서명해시를 만들 때 사용할 입력 목록을 직렬화한 결과를 반환하는 함수
func (t Tx) serializeInputsForSig(inputIndex int, redeemScripts ...*script.Script) ([]byte, error) {
inputs := t.Inputs
result := utils.EncodeVarint(len(inputs)) // 입력 개수
for i, input := range inputs {
var scriptSig *script.Script // 해제 스크립트, 기본값은 nil
if i == inputIndex { // 입력 인덱스가 inputIndex와 같으면
if len(redeemScripts) > 0 { // 리딤 스크립트가 있으면
scriptSig = redeemScripts[0] // 리딤 스크립트를 사용
} else {
scriptPubKey, err := input.ScriptPubKey(NewTxFetcher(), t.Testnet) // 이전 트랜잭션 출력의 잠금 스크립트를 가져옴
if err != nil {
return nil, err
}
scriptSig = scriptPubKey // 이전 트랜잭션 출력의 잠금 스크립트를 사용
}
}
s, err := NewTxIn(input.PrevTx, input.PrevIndex, scriptSig, input.SeqNo).Serialize() // scriptSig를 사용하는 새로운 입력을 생성하고 직렬화
if err != nil {
return nil, err
}
result = append(result, s...) // 직렬화한 결과를 result에 추가
}
return result, nil // 직렬화한 결과를 반환
}
Tx 구조체의 VerifyInput 메서드 수정
// 트랜잭션의 입력을 검증하는 함수
func (t Tx) VerifyInput(inputIndex int) (bool, error) {
if inputIndex >= len(t.Inputs) {
return false, fmt.Errorf("input index %d greater than the number of inputs %d", inputIndex, len(t.Inputs))
}
input := t.Inputs[inputIndex] // 입력을 가져옴
scriptSig := input.ScriptSig // 해제 스크립트
scriptPubKey, err := input.ScriptPubKey(NewTxFetcher(), t.Testnet) // 이전 트랜잭션 출력의 잠금 스크립트를 가져옴
if err != nil {
return false, err
}
var redeemScripts []*script.Script // 리딤 스크립트 목록
if script.IsP2shScriptPubkey(scriptPubKey.Cmds) { // 이전 트랜잭션 출력의 잠금 스크립트가 P2SH 스크립트인 경우
rawRedeem, ok := scriptSig.Cmds[len(scriptSig.Cmds)-1].([]byte) // 해제 스크립트의 마지막 원소가 리딤 스크립트
if !ok {
return false, fmt.Errorf("last element should be the redeem script")
}
redeemScript, _, err := script.Parse(append([]byte{byte(len(rawRedeem))}, rawRedeem...)) // 리딤 스크립트 파싱
if err != nil {
return false, err
}
redeemScripts = append(redeemScripts, redeemScript)
}
z, err := t.SigHash(inputIndex, redeemScripts...) // 서명해시를 가져옴
if err != nil {
return false, err
}
combined := scriptSig.Add(scriptPubKey) // 해제 스크립트와 잠금 스크립트를 결합
return combined.Evaluate(z) // 결합한 스크립트를 평가
}
테스트
package main
import (
"chapter08/tx"
"encoding/hex"
"fmt"
)
func main() {
testVerifyInput()
}
func testVerifyInput() {
txBytes, _ := hex.DecodeString("0100000001868278ed6ddfb6c1ed3ad5f8181eb0c7a385aa0836f01d5e4789e6bd304d87221a000000db00483045022100dc92655fe37036f47756db8102e0d7d5e28b3beb83a8fef4f5dc0559bddfb94e02205a36d4e4e6c7fcd16658c50783e00c341609977aed3ad00937bf4ee942a8993701483045022100da6bee3c93766232079a01639d07fa869598749729ae323eab8eef53577d611b02207bef15429dcadce2121ea07f233115c6f09034c0be68db99980b9a6c5e75402201475221022626e955ea6ea6d98850c994f9107b036b1334f18ca8830bfff1295d21cfdb702103b287eaf122eea69030a0e9feed096bed8045c8b98bec453e1ffac7fbdbd4bb7152aeffffffff04d3b11400000000001976a914904a49878c0adfc3aa05de7afad2cc15f483a56a88ac7f400900000000001976a914418327e3f3dda4cf5b9089325a4b95abdfa0334088ac722c0c00000000001976a914ba35042cfe9fc66fd35ac2224eebdafd1028ad2788acdc4ace020000000017a91474d691da1574e6b3c192ecfb52cc8984ee7b6c568700000000")
txObj, _ := tx.ParseTx(txBytes, false)
ok, err := txObj.VerifyInput(0)
fmt.Println(ok, err)
}
$ go run main.go
true <nil>
📖 참고자료
글에서 수정이 필요한 부분이나 설명이 부족한 부분이 있다면 댓글로 남겨주세요!
'블록체인 > Bitcoin' 카테고리의 다른 글
밑바닥부터 시작하는 비트코인 - 10장 네트워킹 (1) | 2024.01.08 |
---|---|
밑바닥부터 시작하는 비트코인 - 9장 블록 (0) | 2023.09.21 |
밑바닥부터 시작하는 비트코인 - 7장 트랜잭션 검증과 생성 (1) | 2023.09.16 |
밑바닥부터 시작하는 비트코인 - 6장 스크립트 (0) | 2023.09.11 |
밑바닥부터 시작하는 비트코인 - 5장 트랜잭션 (0) | 2023.09.05 |