Swift - 라이브러리

WalletCore를 이용한 비트코인 트랜잭션 생성부터 서명

Goniii 2025. 10. 14. 18:52

지난 글에서는 트랜잭션이 서명 → 전파 → 검증 → 블록 포함 → 채굴 확정으로 이어지는 블록체인의 핵심 데이터 단위라는 점을 살펴보았다

 

비트코인 네트워크에서 각 트랜잭션은 이전 거래의 출력(UTXO)을 입력으로 사용하고, 새로운 출력(UTXO)을 생성하며, 이 모든 과정은 개인키로의 서명과 공개키 검증을 통해 보장된다

 

이 트랜잭션들이 모여 블록을 구성하고, 채굴(Proof of Work)을 통해 네트워크 합의에 도달함으로써 블록체인은 신뢰 가능한 분산 원장으로 완성된다

 

이제 이 이론을 실제 코드로 구현해보자

WalletCore를 이용하여 비트코인 트랜잭션을 직접 생성하고 서명(Sign)하는 로직을 살펴보자

 

TrustWallet의 공식 GitHub에는 비트코인 네트워크에서의 트랜잭션 생성 및 서명 테스트 코드가 포함되어 있다

 

이를 참고하여, 직접 UTXO(미사용 출력) 를 지정하고 Input + Output을 구성해 하나의 트랜잭션을 서명하는 과정을 구현해보자.

https://github.com/trustwallet/wallet-core/blob/master/swift/Tests/Blockchains/BitcoinTests.swift

 

wallet-core/swift/Tests/Blockchains/BitcoinTests.swift at master · trustwallet/wallet-core

Cross-platform, cross-blockchain wallet library. Contribute to trustwallet/wallet-core development by creating an account on GitHub.

github.com

 

 

 

“내가 가진 두 개의 비트코인 조각(UTXO)을 하나로 모아서 다른 주소로 송금하는 트랜잭션을 만들고 서명”하는 로직을 구성해보자

1. 개인키 준비 (WIF 디코딩)

let wif = "L4BeKzm3AHDUMkxLRVKTSVxkp6Hz9FcMQPh18YCKU1uioXfovzwP"
let decoded = Base58.decode(string: wif)!
let key = PrivateKey(data: decoded[1 ..< 33])!
let pubkey = key.getPublicKeySecp256k1(compressed: false)
  • WIF(Wallet Import Format)란?
    • WIF는 비트코인 개인키를 Base58Check 형식으로 인코딩한 문자열
    • 원래의 32바이트 개인키를 사람이 읽고 옮기기 쉽게 만든 형태
    • 다른 지갑으로 개인키를 내보내기(Export) 또는 불러오기(Import) 할 때 사용
    • Base58 문자열이므로 WalletCore에서 사용하려면 원래의 32바이트 바이너리 형태로 디코딩해야 함
    • 내부 바이트 구조(메인넷 기준, 37~38 Byte):
      1. Version - 1 바이트 → 네트워크 구분자(0x80: 메인넷)
      2. Private Key(개인키) - 32바이트
      3. Compression Flag - 0 or 1 바이트 [선택] → 0x01 (압축 표시 1바이트)
      4. Checksum 4바이트(= double-SHA256의 앞 4바이트)
    • 따라서 실제 개인키(32바이트)는 decoded[1 ..< 33] 구간
  • key.getPublicKeySecp256k1(compressed:)
    • 개인키에 secp256k1(타원곡선 알고리즘)을 적용하여 공개키를 만드는 메서드
    • compressed: false → 비압축 공개키(65바이트) 생성
    • compressed: true → 압축 공개키(33바이트) 생성
    → 사용하려는 UTXO의 주소가 어떤 공개키 형식으로 만들어졌는지(압축/비압축)에 따라가야 함(일치해야 함)보통 세그윗 업데이트 이후에는 압축 공개키를 사용함
  • 왜냐, 같은 개인키라도 공개키 형식에 따라 서로 다른 주소가 생기기 때문

 

2. 주소 및 LockScript 생성(UTXO 대입용)

let address = BitcoinAddress(data: [0x0] + Hash.sha256RIPEMD(data: pubkey.data))!
let script = BitcoinScript.lockScriptForAddress(address: address.description, coin: .bitcoin)
  1. 공개키 → 주소 변환
    • 비트코인 주소는 “공개키를 해시한 결과”
    • SHA256 → RIPEMD160 해시를 적용하면 20바이트의 key hash가 생김 (= hash160)
      • SHA-256: 32바이트의 암호학적 해시를 만듦
      • RIPEMD-160: 32바이트를 다시 20바이트로 축소 (더 짧고 주소에 적합)
    • 여기에 [0x00](mainnet prefix) 붙이면 Legacy 주소(1…) 형식이 됨(세그윗 이전의 비트코인 주소 형식)
    • 이 주소를 사람이 보기 좋게 Base58Check로 인코딩하면 최종 주소가 됨
  2. Lock Script 생성
    • 해당 주소로 비트코인을 잠그는 스크립트(scriptPubKey)를 생성함
    • “해당 UTXO가 나의 주소로 잠겨 있었음”을 표현하는 참조용 데이터
    • “이 비트코인을 이 주소의 공개키 해시에 해당하는 사람만 사용할 수 있다” 라는 조건문을 만드는 것
    • WalletCore에서는 BitcoinScript.lockScriptForAddress(address:) 가 이 역할을 수행함
    • 이후 설명할 UTXO 객체를 생성하는 과정에서 “이 UTXO가 어떤 조건으로 잠겨 있었는지” 동일하게 설정하기 위해 필요함
💡 BitcoinScript
  • 비트코인 트랜잭션 검증 언어
  • 스택 기반 언어로, 데이터를 푸시(push)하고 명령어(OPCODE)를 순서대로 실행하는 구조
  • 무한 루프 불가, 항상 종료되는 프로그램만 작성 가능 (결정성과 보안을 위해)
  • 트랜잭션에는 두 종류의 스크립트가 존재
이름 역할 예시
scriptPubKey “받을 때” 조건 (Output) 이 돈은 공개키 해시 X가 서명해야 쓸 수 있음
scriptSig “쓸 때” 조건 (Input) 실제 서명(signature)과 공개키

 

트랜잭션 검증 시 이 두 스크립트를 연결해서 실행함

scriptSig + scriptPubKey → 실행 → True면 유효!

 

 

3. 내가 가진 코인(UTXO) 설정

→ 과거에 내 주소로 들어왔고 아직 안 쓴 출력(UTXO) 두 개를, 이번 전송에서 소비할 입력으로 지정

let utxos: [BitcoinUnspentTransaction] = [
    .with {
        $0.outPoint.hash = Data.reverse(hexString: "6ae3f1d24552...")
        $0.outPoint.index = 0
        $0.outPoint.sequence = UINT32_MAX
        $0.amount = 16874
        $0.script = script.data
    },
    .with {
        $0.outPoint.hash = Data.reverse(hexString: "fd1ea8178228...")
        $0.outPoint.index = 0
        $0.amount = 10098
        $0.script = script.data
    }
]
  • outPoint.hash
    • 이 UTXO가 속한 과거 트랜잭션의 txid(32바이트)
    • 비트코인 raw 트랜잭션 포맷은 리틀엔디언으로 txid를 넣기 때문에 보통 익스플로러(빅엔디언 표기)에서 본 16진수를 바이트 역순으로 넣어야 함
      • 빅 엔디안(Big Endian): 저장할 때 상위 바이트. 즉, 큰 쪽을 먼저 저장하는 것
      • 리틀 엔디안(Little Endian): 저장할 때 하위 바이트. 즉, 작은 쪽을 먼저 저장하는 것
  • outPoint.index
    • 하나의 트랜잭션에는 여러 Output이 존재함 → 그 중 몇 번째를 쓸거냐
    • 그 트랜잭션의 몇 번째 출력을 소비하겠다는 뜻(0부터 시작)
    • txid가 같아도 index가 다르면 다른 UTXO
  • outPoint.sequence
    • 트랜잭션이 유효해지는 시점을 제어하는 시퀀스 번호.
    • 기본값 UINT32_MAX (0xFFFFFFFF)이면 즉시 유효 (nLockTime 미사용)
    • 수수료 재조정(RBF, Replace-by-Fee) 시에도 활용됨
  • amount (사토시 단위)
    • 이 UTXO 출력의 정확한 금액(1 BTC = 100,000,000 sat).
    • 왜 필요한가?
      • 서명 해시 계산(BIP-143, SegWit) 시 금액이 포함됨.
      • 입력합 – 출력합 = 수수료(Fee) 계산에도 사용됨.
    • 틀리면? → 수수료 계산 오차, SegWit라면 서명 자체가 무효.
  • script(scriptPubKey: 잠금 스크립트)
    • 이 UTXO가 어떤 조건으로 잠겨 있는지(주소 타입에 대응) 나타내는 locking script
    • 이 UTXO가 나(송신자)의 주소로 잠겨 있었음을 나타내는 정보

 

 

4. 송금할 트랜잭션 만들기(Input 생성)

 

BitcoinSigningInput: 트랜잭션 생성에 필요한 모든 정보를 담는 구조체

BitcoinSigningInput은 송신자의 Input(소비할 UTXO)수신자의 Output(새로 만들 UTXO) 정보를 동시에 담고 있는 구조

let input = BitcoinSigningInput.with {
    $0.utxo = utxos
    $0.privateKey = [key.data]
    $0.hashType = BitcoinScript.hashTypeForCoin(coinType: .bitcoin)
    $0.useMaxAmount = true
    $0.byteFee = 10
    $0.toAddress = "1FeyttPotRsSd4equWr678dbEvXaNSqmDT"
    $0.coinType = CoinType.bitcoin.rawValue
    $0.amount = utxos.map { $0.amount } .reduce(0, +)
}

 

필드 설명
utxo 내가 소비할 입력(동전들)
privateKey 각 입력에 대한 서명을 만들 개인키
hashType 어떤 서명 규칙을 쓸지
useMaxAmount = true 전체 금액을 다 사용하겠다는 의미 → change 없음
byteFee = 10 1 byte당 수수료 10 satoshi (fee 계산용)
toAddress 수신자 주소
amount 전송 금액 (모든 UTXO의 합계)
  • toAddress → 새 Output의 잠금 스크립트(scriptPubKey)로 사용됨
  • privateKey → 각 Input의 해제 스크립트(scriptSig)로 사용됨

→ amount, fee, change, utxos 목록은 BitcoinTransactionPlan을 사용하는 것이 일반적

 

 

5. 서명 및 트랜잭션 생성

let output: BitcoinSigningOutput = AnySigner.sign(input: input, coin: .bitcoin)
  • AnySigner.sign()은 WalletCore의 핵심 엔진
  • WalletCore는 하나의 통일된 API로 비트코인, 이더리움, 코스모스, 솔라나 등 다수 체인의 서명/인코딩 규칙을 자동 적용
  • 내부 실행 로직
단계  역할
1. Input 처리 utxo 리스트를 바탕으로 소비할 입력(이전 Output) 결정 (유효성 체크)
2. Output 구성 toAddress를 기반으로 새로운 Output(=새 UTXO) 생성
내부적으로 수신자 주소의 공개키 해시로 잠금 스크립트(scriptPubKey) 자동 생성
3. Fee 계산 byteFee와 amount를 바탕으로 change 및 수수료 계산
4. 서명(Signing) 내 privateKey로 각 Input을 서명
5. 트랜잭션 직렬화 (Serialization) 위에서 생성된 Input, Output, Script, Signature를 모두 결합하여
최종 Raw Transaction 바이트 배열 생성
6. 결과 반환 (Output) output.encoded - 네트워크로 전송 가능한 Raw TX 반환

 

전체 코드

    // UTXO(미사용 출력)를 직접 지정해서 Input + Output 구성
    func signExtendedPubkeyUTXO() {
        let wif = "L4BeKzm3AHDUMkxLRVKTSVxkp6Hz9FcMQPh18YCKU1uioXfovzwP" // Base58로 인코딩한 비트코인 비밀키
        let decoded = Base58.decode(string: wif)! // 인코딩된 비밀키를 디코딩
        let key = PrivateKey(data: decoded[1..<33])! // 개인키는 32바이트, 0번째 인덱스는 네트워크 구분자,마지막은 체크섬

        // 압축 여부에 따라 주소 결과도 달라짐 -> 사용하는 UTXO의 주소의 형식(압축/비압축)과 일치시켜야 함
        let pubkey = key.getPublicKeySecp256k1(compressed: false) // 공개키 생성(비압축: 65바이트, 압축 33바이트)

        let address = BitcoinAddress(data: [0x0] + Hash.sha256RIPEMD(data: pubkey.data))! // 주소 생성: 공개키를 해시한 결과
        let script = BitcoinScript.lockScriptForAddress(address: address.description, coin: .bitcoin) // 해당 주소로 비트코인을 잠그는 스크립트 생성 -> 사용하려면 해당 공개키와 유효한 서명 필요

        // 내가 가진 코인(UTXO) 설정
        let utxos: [BitcoinUnspentTransaction] = [
            .with {
                // 이 UTXO가 속한 과거 트랜잭션의 txid
                // 비트코인 raw 트랜잭션 포맷은 리틀엔디언으로 txid를 넣기 때문에 reverse 필수
                $0.outPoint.hash = Data.reverse(hexString: "6ae3f1d245521b0ea7627231d27d613d58c237d6bf97a1471341a3532e31906c")

                // 하나의 트랜잭션에는 여러 개의 Output이 존재할 수 있음 -> 그 중 몇 번째를 쓸거냐
                $0.outPoint.index = 0

                // 트랜잭션이 언제 유효해지는지 제어하는 프로퍼티(locktime 활성화, 수수료 재조정 가능)
                // 기본값: UINT32_MAX -> 즉시 유효 (locktime 없이 사용)
                $0.outPoint.sequence = UINT32_MAX

                // 이 UTXO 출력의 정확한 금액(사토시 단위)
                $0.amount = 16874

                // 이 UTXO가 어떤 조건으로 잠겨 있는지(주소 타입에 대응) 나타내는 locking script
                $0.script = script.data
            },
            .with {
                $0.outPoint.hash = Data.reverse(hexString: "fd1ea8178228e825d4106df0acb61a4fb14a8f04f30cd7c1f39c665c9427bf13")
                $0.outPoint.index = 0
                $0.outPoint.sequence = UINT32_MAX
                $0.amount = 10098
                $0.script = script.data
            }
        ]

        let input = BitcoinSigningInput.with {
            $0.utxo = utxos // 소비할 UTXO
            $0.privateKey = [key.data] // 각 입력에 대한 서명을 만들 개인키
            $0.hashType = BitcoinScript.hashTypeForCoin(coinType: .binance) // 서명 규칙
            $0.useMaxAmount = true // 전체 금액을 다 사용하겠다는 의미 -> change 없음
            $0.byteFee = 10 // 1 바이트 당 수수료 10 사토시
            $0.toAddress = "1FeyttPotRsSd4equWr678dbEvXaNSqmDT" // 받는 사람 주소
            $0.coinType = CoinType.bitcoin.rawValue // 코인 타입
            $0.amount = utxos.map { $0.amount }.reduce(0, +) // 전송 금액(모든 UTXO 합계)
        }

        let ouput: BitcoinSigningOutput = AnySigner.sign(input: input, coin: .bitcoin) // 서명 및 트랜잭션 생성
        pubkeyUTXOOutput = ouput.encoded.hexString
    }
728x90