비트코인은 프로그래밍이 가능한 돈이다. 비트코인을 소비할 수 있는 다양한 조건을 코드로 표현할 수 있다.
코드는 잠금/해제 스크립트(또는 Witness)에 포함되며 opcode라고 불리는 명령어들의 조합으로 구성된다.
P2PKH 같은 스크립트 유형은 정해진 명령어 조합 안에서만 사용할 수 있지만 P2SH, P2TR 등은 허용된 용량 안에서 자유롭게 코드를 작성할 수 있다.
이전 글에서는 P2PKH, P2SH에 대해 알아봤다. 이번 글에서는 P2TR에서 직접 스크립트를 작성하는 방법에 대해 알아보자.
이번 글에서는 bitcoinjs-lib를 활용한다. 아래 명령어를 실행해서 프로젝트 환경을 구축하자.
typescript 코드 실행을 위해 bun을 이용한다. bun 사이트에서 설치 방법을 따라하자.
P2TR 주소 생성을 위한 개인키를 만들자. 32 바이트 길이의 문자열이 필요한데, 직접 아무 값이나 입력해도 된다.
혹은 아래와 같이 코드를 이용해서 생성할 수도 있다.
공개키/개인키 생성을 위해 ECPair 객체를 만들어서 사용했다.
코드 실행 결과는 다음과 같다.
앞에서 생성한 개인키를 이용해서 P2TR 주소를 생성해보자.
직접 작성하는 스크립트 없이 키 경로만 사용하는 P2TR 주소를 만들 수도 있다. 이번 글에서는 스트립트 경로를 사용하는 P2TR에 대해서 다룬다.
P2TR에서 사용할 두 개의 스크립트를 준비하자.
첫 번째 스크립트(scriptAsm1)는 공개키를 이용한 일반적인 잠금 스크립트 형식이다. 두 번째 스크립트(scriptAsm2)는 더해서 5가 되는 두 숫자를 입력해야 풀리는 잠금 스크립트다. 두 스크립트 중에 원하는 하나의 스크립트를 선택해서 소비할 수 있는 P2TR 주소를 만들 예정이다.
여기서 toXOnly 함수가 다소 생소하다.
공개키는 33 바이트지만 Taproot에서 사용되는 공개키(internalPubkey)는 x 좌표만 포함하는 32 바이트다. 나머지 1 바이트는 y 좌표를 의미하는데 Taproot에서는 사용하지 않으므로 제거한다.
bitcoinjs-lib에서 toXOnly 함수의 구현은 아래와 같다.
스크립트가 하나가 아니라면 여러 스크립트를 트리 형태로 구성해야 한다. 우리는 두 개의 리프(leaf) 노드를 갖는 트리로 구성해보자.
p2tr 객체를 만들 때 입력한 값이 실제로 P2TR 주소 생성을 위해 필요한 모든 값이다. 즉, P2TR 주소는 공개키와 스크립트로 생성할 수 있다.
코드를 실행해보자.
bitcoin-cli를 이용해서 앞에서 생성한 P2TR 주소로 비트코인을 보내자.
bitcoin-cli에서 P2TR 주소의 UTXO를 확인할 수 있도록 지갑을 생성하자.
입력 편의를 위해 bc-tr
별칭을 만들었다.
생성한 지갑으로 P2TR 주소의 descriptor를 입력한다. 이때 checksum이 포함된 descriptor를 얻기 위해 getdescriptorinfo 명령어를 이용한다.
listunspent 명령어로 UTXO를 확인해보자.
0.1 비트코인이 잘 들어온 것을 확인할 수 있다.
P2TR 비트코인을 사용하기 위해서는 준비된 스크립트 중에 하나를 선택해야 한다. 먼저 두 번째 스크립트인 OP_ADD OP_5 OP_EQUAL
를 이용해보자.
redeem 객체의 output에 우리가 선택한 두 번째 스크립트를 입력했다.
redeemVersion에는 BIP 표준에서 정의된 버전 정보를 입력한다. 현재는 초기 단계라서 별다른 옵션이 없고 LEAF_VERSION_TAPSCRIPT(0xC0
)를 입력하면 된다. 0xC0
는 tapscript spend를 의미한다.
p2tr 객체를 생성할 때 이전과는 다르게 redeem 정보를 입력했다. P2TR에서 스크립트 경로로 비트코인을 사용하기 위해서는 control block
이라는 것이 필요한데, redeem 정보를 이용해서 control block
값을 얻을 수 있다.
p2tr.output
는 잠금 스크립트다.
witness 데이터의 마지막에는 항상 control block
이 들어간다는 사실을 이용해서 controlBlock 변수의 값을 입력했다.
현재 p2tr.witness
는 아래 내용을 담고 있다.
p2tr.witness[0]
는 우리가 사용하는 두 번째 스크립트의 내용이다. p2tr.witness[1]
는 앞에서 언급한대로 control block
이다.
control block
은 다음의 내용을 포함한다.
0xC0
= 192
)우리 코드에서는 65(1+32+32
) 바이트 크기의 control block
이 만들어진다.
트랜잭션 생성을 위해서 PSBT를 이용한다. PSBT 객체를 만들고 트랜잭션의 입출력 값을 입력하자.
입력에는 앞에서 확인했던 UTXO 정보를 입력하자.
출력에는 다른 사람의 주소와 우리의 P2TR 주소를 각각 입력하자.
witnessUtxo
는 서명을 위해 필요한 정보다.
아직 psbt
객체는 서명이 안된 상태다. finalizeInput 메서드를 이용해서 서명을 해보자.
보통 witness
배열 앞쪽에는 서명 데이터가 들어간다. 우리가 사용하는 스크립트는 더해서 5가 되는 두 개의 숫자가 필요하므로 2와 3을 입력했다. 이어서 우리가 사용하는 스크립트의 내용과 control block
을 입력했다. 앞에서 언급한대로 control block
은 마지막에 입력해야 한다.
witnessStackToScriptWitness 함수는 witness 배열을 트랜잭션 형식에 맞게 변환해준다. witnessStackToScriptWitness 가 반환하는 Buffer에는 아래 값이 들어있다.
앞에 04
는 4개의 데이터가 있다는 것을 의미하며 각 데이터는 아래와 같이 구분할 수 있다.
0102
이다. 앞에 01
은 1 바이트 데이터가 있다는 것을 의미한다.0103
이다.03935587
이다. 03
은 3 바이트 데이터가 있다는 것을 의미한다. 이것은 redeem.output
를 의미한다.controlBlock
이다. 41
은 65 바이트 데이터가 있다는 것을 의미한다.서명이 완료되었으므로 raw 트랜잭션 데이터를 추출하자.
코드를 실행해보자.
decoderawtransaction 명령어를 이용해서 트랜잭션 데이터를 검증해보자.
사용하는 UTXO는 P2TR 주소이므로 해제 스크립트는 비어있고 대신 txinwitness 필드에 필요한 값이 채워져있다. 우리가 입력한 숫자 2와 3이 보인다.
출력 데이터도 우리가 입력한 대로 잘 들어가 있다.
생성된 트랜잭션을 전송하자.
UTXO를 확인해보자.
0.1 비트코인에서 수수료를 제외한 나머지 절반이 잘 들어왔다.
이번에는 OP_CHECKSIG를 사용하는 첫 번째 스크립트를 이용해보자.
첫 번째 스크립트는 두 번째 스크립트와는 다르게 공개키를 이용한 일반적인 잠금 스크립트를 이용하고 있다. 따라서 트랜잭션을 서명하는 과정이 이전 방식보다 훨씬 간단하다.
이전과 같이 P2TR 주소로 0.1 비트코인을 보내주자.
UTXO 정보를 확인해보자.
앞에서 확인한 txid
, vout
값을 코드에 입력하자.
기존에 작성한 redeem 객체를 조금 수정해서 첫 번째 스크립트를 사용하도록 하자.
트랜잭션 입력을 구성하는 코드도 추가 작업이 필요하다.
tapLeafScript
속성을 추가로 입력했다. 이전에는 수동으로 witness 데이터를 생성했지만 이제 tapLeafScript
덕분에 witness 데이터를 자동으로 생성할 수 있다. 이는 첫 번째 스크립트가 일반적인 잠금 스크립트 형태이기 때문이다.
이전에는 customFinalizer
함수를 작성했지만 이제는 필요 없다.
이렇게 서명이 완료됐다. 코드를 실행해보자.
생성된 raw 트랜잭션에서 witness 데이터 부분은 아래와 같다.
그리고 이를 각 부분별로 구분해서 확인해보자.
03
은 3개의 데이터가 있다는 것을 의미한다.40
은 64 바이트 데이터가 있다는 것을 의미한다. 이것은 서명 데이터다.22
는 34 바이트 데이터가 있다는 것을 의미한다. 이것은 우리가 사용하는 첫 번째 스크립트의 내용이다.41
은 65 바이트 데이터가 있다는 것을 의미한다. 이것은 control block
이다.이제 트랜잭션을 전송해보자.
트랜잭션 ID가 출력됐으므로 서명이 잘 된 트랜잭션이라는 것을 알 수 있다.
조금 더 복잡한 구조로 스크립트를 구성해보고 control block
이 어떻게 달라지는지 확인해보자.
2-depth tree를 구성하기 위해 아래처럼 3개의 스크립트를 만들자.
세 번째 스크립트를 사용하는 control block
을 출력하자.
코드를 실행해보자.
이전 control block
의 크기는 65 바이트였는데, 32 바이트가 늘어났다. 이는 1 depth가 늘어난 결과다.
지금까지 작성한 코드는 여기에서 확인할 수 있다.