지난 글에서는 트랜잭션이 서명 → 전파 → 검증 → 블록 포함 → 채굴 확정으로 이어지는 블록체인의 핵심 데이터 단위라는 점을 살펴보았다
비트코인 네트워크에서 각 트랜잭션은 이전 거래의 출력(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):
- Version - 1 바이트 → 네트워크 구분자(0x80: 메인넷)
- Private Key(개인키) - 32바이트
- Compression Flag - 0 or 1 바이트 [선택] → 0x01 (압축 표시 1바이트)
- Checksum 4바이트(= double-SHA256의 앞 4바이트)
- 따라서 실제 개인키(32바이트)는 decoded[1 ..< 33] 구간
- key.getPublicKeySecp256k1(compressed:)
- 개인키에 secp256k1(타원곡선 알고리즘)을 적용하여 공개키를 만드는 메서드
- compressed: false → 비압축 공개키(65바이트) 생성
- compressed: true → 압축 공개키(33바이트) 생성
- 왜냐, 같은 개인키라도 공개키 형식에 따라 서로 다른 주소가 생기기 때문
2. 주소 및 LockScript 생성(UTXO 대입용)
let address = BitcoinAddress(data: [0x0] + Hash.sha256RIPEMD(data: pubkey.data))!
let script = BitcoinScript.lockScriptForAddress(address: address.description, coin: .bitcoin)
- 공개키 → 주소 변환
- 비트코인 주소는 “공개키를 해시한 결과”
- SHA256 → RIPEMD160 해시를 적용하면 20바이트의 key hash가 생김 (= hash160)
- SHA-256: 32바이트의 암호학적 해시를 만듦
- RIPEMD-160: 32바이트를 다시 20바이트로 축소 (더 짧고 주소에 적합)
- 여기에 [0x00](mainnet prefix) 붙이면 Legacy 주소(1…) 형식이 됨(세그윗 이전의 비트코인 주소 형식)
- 이 주소를 사람이 보기 좋게 Base58Check로 인코딩하면 최종 주소가 됨
- 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
}'Swift - 라이브러리' 카테고리의 다른 글
| WalletCore를 이용한 이더리움 트랜잭션 생성부터 서명 (0) | 2025.10.15 |
|---|---|
| WalletCore를 이용한 HDWallet/니모닉/키의 경로 체계 이해 (0) | 2025.10.13 |
| Swift - [SnapKit] snp.updateConstraints() (0) | 2024.10.20 |
| 네이버 지도 SDK 사용 (SwiftUI) (1) | 2024.03.03 |
| Swift - DropDown 라이브러리 사용 (1) | 2023.08.15 |