지난 포스팅에서 블록에 대해서 다뤄봤어요. 블록이 어떻게 구성되어 있는지, 각 구성 요소들이 어떤 역할을 하는지 주로 살펴보았어요. 하지만 깊게 다루지 않고 넘어간 것이 있는데 블록에 저장되는 트랜잭션들이에요. 이번 포스팅에서는 트랜잭션이 어떻게 구성되어 있는지 다뤄볼거에요.
트랜잭션(Transaction)이란?
기본 개념
트랜잭션은 ‘거래’라는 의미에요. 비트코인 네트워크에서 트랜잭션이 의미하는 바는 하나에요. A가 B에게 송금을 한 사실이 곧 거래이고 이것이 트랜잭션이에요. 조금 더 상세히 설명하자면 비트코인을 한 주소에서 다른 주소로 전송하는 거래를 의미해요. 발생한 거래는 영구적으로 네트워크에 저장되어 남게 되어요.
UTXO(Unspent Transaction Output) 모델
비트코인에서는 우리에게 일반적으로 친숙한 잔액의 개념 대신 UTXO 모델을 사용해요. 얼마를 보유하고 있는지에 대해서 총 잔액을 계속 관리하는 것이 아니라 사용하지 않은 남은 잔액을 활용하는 방식이에요.
- 일반적인 잔액 모델
- 통장을 만들었음
- 초기 상태 0원
- 1000원을 받음
- 잔액 1000원
- 2000원을 받음
- 잔액 3000원
- 3000원을 받음
- 잔액 6000원
- 5000원을 보냄
- 잔액 1000원
- 통장을 만들었음
- UTXO 모델
- 통장을 만들었음
- 0원
- 1000원을 받음
- 사용하지 않은 잔액 1000원 발생
- 2000원을 받음
- 사용하지 않은 잔액 2000원 발생
- 3000원을 받음
- 사용하지 않은 잔액 3000원 발생
- 5000원을 보냄
- 사용하지 않은 잔액이 총 1000원, 2000원, 3000원 이 존재
- 사용하지 않은 잔액 2000원, 3000원을 모두 사용
- 결과적으로 사용하지 않은 잔액 1000원만 남게됨
- 통장을 만들었음
잔액 모델 대신에 UTXO 이 채택된 이유는 이중지불 문제를 방지할 가능성이 더 크기 때문이에요. 프로그램에서 통용되는 용어로는 ‘동시성 이슈’를 사용할 수 있어요.
- 잔액 모델 사용시
- A의 잔액이 1000원
- B에게 700원, C에게 800원 송금이 동시에 발생
- B와 C의 거래가 동시에 진행되면 둘 다 현재 잔액이 1000원이라고 판단하고 충분한 잔액이 있으므로 송금이 진행
- 결과적으로 1000원 밖에 없는데 B와 C가 각각 송금을 받게 됨
- UTXO 모델 사용시
- A의 1000원짜리 UTXO 가 하나가 존재
- B에게 700원, C에게 800원 송금이 동시에 발생했음(= 하나의 UTXO 가 두 트랜잭션에서 사용됨)
- 블록에 두 트랜잭션이 모두 포함이 되어 다른 노드에게 합의를 거치는 과정에서 하나의 UTXO가 두 개의 트랜잭션에 사용되었으므로 합의가 되지 않아 거절이 됨.
UTXO 모델에서 이중지불 문제가 사용된 경우를 조금 더 자세히 볼게요.
- 시나리오 1: 다른 블록에 포함된 경우
- 블록 #100
- 트랜잭션1: UTXO_A 사용 → 검증 통과 → 채굴 성공
- 블록 #101
- 트랜잭션2: UTXO_A 사용 시도
→ 검증 실패 (이미 블록 #100에서 사용됨)
→ 해당 트랜잭션은 거절
- 트랜잭션2: UTXO_A 사용 시도
- 블록 #100
- 시나리오 2: 같은 블록에 포함된 경우
- 블록 #100
- 트랜잭션1: UTXO_A 사용
- 트랜잭션2: UTXO_A 사용
→ 유효하지 않은 블록 (이중 지불 시도)
→ 블록 전체가 거절됨
- 블록 #100
트랜잭션의 생성과 전파
이건 채굴과 깊은 교집합이 있는 내용이에요. 채굴 과정 자체에 대해서는 별도의 포스팅으로 다룰 예정이라 여기서는 간략하게 만 정리하고자 해요.
거래가 네트워크로 전송되면 이를 가장 먼저 접하는 노드가 있을 거에요. 이 노드는 트랜잭션을 검증하고 밈풀에 저장이 되어요.(밈풀이란 각 노드의 거래 저장소라고만 이해해도 충분해요) 이 노드에 의해서 거래가 다른 노드들로 전파되고 똑같은 절차를 거치게 되어요.
채굴 노드들은 블록을 구성할 때 밈풀에서 가장 높은 수수료를 가진 거래들을 선택하여 구성하고자 하고 블록이 채굴되면 다른 노드들의 합의 과정에서 다시 한번 더 검증을 하게 되고 결국 블록이 연결되면 거래가 영구적으로 저장이 되어요.
트랜잭션(Transaction) 구조
트랜잭션의 구조에 대해서 알아 볼게요. 이전 포스팅에서 블록의 구조를 알아본 것과 유사하게 트랜잭션이 어떤 것들로 구성되는지, 각 구성요소는 어떤 역할을 하는지 알아 볼게요.
struct CMutableTransaction
{
std::vector<CTxIn> vin;
std::vector<CTxOut> vout;
uint32_t version;
uint32_t nLockTime;
explicit CMutableTransaction();
explicit CMutableTransaction(const CTransaction& tx);
template <typename Stream>
inline void Serialize(Stream& s) const {
SerializeTransaction(*this, s, s.template GetParams<TransactionSerParams>());
}
template <typename Stream>
inline void Unserialize(Stream& s) {
UnserializeTransaction(*this, s, s.template GetParams<TransactionSerParams>());
}
template <typename Stream>
CMutableTransaction(deserialize_type, const TransactionSerParams& params, Stream& s) {
UnserializeTransaction(*this, s, params);
}
template <typename Stream>
CMutableTransaction(deserialize_type, Stream& s) {
Unserialize(s);
}
/** Compute the hash of this CMutableTransaction. This is computed on the
* fly, as opposed to GetHash() in CTransaction, which uses a cached result.
*/
Txid GetHash() const;
bool HasWitness() const
{
for (size_t i = 0; i < vin.size(); i++) {
if (!vin[i].scriptWitness.IsNull()) {
return true;
}
}
return false;
}
};
트랜잭션(Transaction) 구조
트랙잭션의 구성요소는 아래와 같아요.
- 입력들(n 개)
- 출력들(n 개)
- 버전
- 락타임
struct CMutableTransaction
{
std::vector<CTxIn> vin; // 입력들
std::vector<CTxOut> vout; // 출력들
uint32_t version; // 버전
uint32_t nLockTime; // 락타임
explicit CMutableTransaction();
explicit CMutableTransaction(const CTransaction& tx);
template <typename Stream>
inline void Serialize(Stream& s) const {
SerializeTransaction(*this, s, s.template GetParams<TransactionSerParams>());
}
template <typename Stream>
inline void Unserialize(Stream& s) {
UnserializeTransaction(*this, s, s.template GetParams<TransactionSerParams>());
}
template <typename Stream>
CMutableTransaction(deserialize_type, const TransactionSerParams& params, Stream& s) {
UnserializeTransaction(*this, s, params);
}
template <typename Stream>
CMutableTransaction(deserialize_type, Stream& s) {
Unserialize(s);
}
/** Compute the hash of this CMutableTransaction. This is computed on the
* fly, as opposed to GetHash() in CTransaction, which uses a cached result.
*/
Txid GetHash() const;
bool HasWitness() const
{
for (size_t i = 0; i < vin.size(); i++) {
if (!vin[i].scriptWitness.IsNull()) {
return true;
}
}
return false;
}
};
입력(CTxIn)들
- prevout
- 이전 트랜잭션의 해시
- UTXO를 특정하기 위한 참조값
- 어떤 이전 출력을 사용할지 지정
- 이중지불 방지를 위한 명확한 참조
- scriptSig
- 이전 출력의 잠금 스크립트(scriptPubKey)를 풀기 위한 스크립트
- 소유권 증명을 위한 데이터 포함
- scriptWitness
- 기존 scriptSig에서 분리된 서명 데이터
- 세그윗 트랜잭션일때 서명데이터는 여기에 저장됨
- nSequence
- 아래 용도들을 위해 사용되는 필드
- 언제 돈을 쓸 수 있는지
- 트랜잭션을 수정할 수 있는지
- 특별한 조건이 있는지 없는지
- 아래 용도들을 위해 사용되는 필드
여기서 ‘세그윗’ 의 의미를 잠깐 알아볼게요. SegWit(Segregated Withness, 분리된 서명) 이란 비트코인의 트랜잭션 구조를 개선한 프로토콜 업그레이드로, 서명 데이터를 트랜잭션 본문에서 분리하여 블록 용량을 효율적으로 사용하는 방식이에요. 2017년 비트코인 네트워크에 도입되었고, 비트코인의 확장성과 보안성을 향상시키는 중요한 개선이었어요.
세그윗 도입 전에는 서명 데이터가 scriptSig 에 저장 되었고, 세그윗 도입 이후에는 scriptWitness 에 서명 데이터가 저장되어요.
서명 데이터라고 표현한 것은 크게 공개키와 거래 데이터를 가지고 마치 사인을 한 것처럼 개인키로 서명한 데이터 두 가지를 의미해요.
서명 데이터
- 공개키
- 거래 데이터
서명의 간단한 흐름
- 특정 거래의 직렬화 데이터
- 이를 SHA256으로 더블 해싱
- hash = SHA256(SHA256(serialized_tx_data))
- 개인키를 이용해서 ECDSA 알고리즘으로 암호화
- signature = ECDSA_Sign(private_key, hash)
거래에 대한 검증
bool verified = ECDSA_Verify(
public_key, // 공개키
signature, // 서명값
hash // 트랜잭션 해시
);
서명과 검증 소스
bool CKey::Sign(const uint256 &hash, std::vector<unsigned char>& vchSig, bool grind, uint32_t test_case) const {
if (!keydata)
return false;
vchSig.resize(CPubKey::SIGNATURE_SIZE);
size_t nSigLen = CPubKey::SIGNATURE_SIZE;
unsigned char extra_entropy[32] = {0};
WriteLE32(extra_entropy, test_case);
secp256k1_ecdsa_signature sig;
uint32_t counter = 0;
int ret = secp256k1_ecdsa_sign(secp256k1_context_sign, &sig, hash.begin(), UCharCast(begin()), secp256k1_nonce_function_rfc6979, (!grind && test_case) ? extra_entropy : nullptr);
// Grind for low R
while (ret && !SigHasLowR(&sig) && grind) {
WriteLE32(extra_entropy, ++counter);
ret = secp256k1_ecdsa_sign(secp256k1_context_sign, &sig, hash.begin(), UCharCast(begin()), secp256k1_nonce_function_rfc6979, extra_entropy);
}
assert(ret);
secp256k1_ecdsa_signature_serialize_der(secp256k1_context_sign, vchSig.data(), &nSigLen, &sig);
vchSig.resize(nSigLen);
// Additional verification step to prevent using a potentially corrupted signature
secp256k1_pubkey pk;
ret = secp256k1_ec_pubkey_create(secp256k1_context_sign, &pk, UCharCast(begin()));
assert(ret);
ret = secp256k1_ecdsa_verify(secp256k1_context_static, &sig, hash.begin(), &pk);
assert(ret);
return true;
}
bool CKey::VerifyPubKey(const CPubKey& pubkey) const {
if (pubkey.IsCompressed() != fCompressed) {
return false;
}
unsigned char rnd[8];
std::string str = "Bitcoin key verification\n";
GetRandBytes(rnd);
uint256 hash{Hash(str, rnd)};
std::vector<unsigned char> vchSig;
Sign(hash, vchSig);
return pubkey.Verify(hash, vchSig);
}
입력 소스
class CTxIn
{
public:
COutPoint prevout;
CScript scriptSig;
uint32_t nSequence;
CScriptWitness scriptWitness; //!< Only serialized through CTransaction
/**
* Setting nSequence to this value for every input in a transaction
* disables nLockTime/IsFinalTx().
* It fails OP_CHECKLOCKTIMEVERIFY/CheckLockTime() for any input that has
* it set (BIP 65).
* It has SEQUENCE_LOCKTIME_DISABLE_FLAG set (BIP 68/112).
*/
static const uint32_t SEQUENCE_FINAL = 0xffffffff;
/**
* This is the maximum sequence number that enables both nLockTime and
* OP_CHECKLOCKTIMEVERIFY (BIP 65).
* It has SEQUENCE_LOCKTIME_DISABLE_FLAG set (BIP 68/112).
*/
static const uint32_t MAX_SEQUENCE_NONFINAL{SEQUENCE_FINAL - 1};
// Below flags apply in the context of BIP 68. BIP 68 requires the tx
// version to be set to 2, or higher.
/**
* If this flag is set, CTxIn::nSequence is NOT interpreted as a
* relative lock-time.
* It skips SequenceLocks() for any input that has it set (BIP 68).
* It fails OP_CHECKSEQUENCEVERIFY/CheckSequence() for any input that has
* it set (BIP 112).
*/
static const uint32_t SEQUENCE_LOCKTIME_DISABLE_FLAG = (1U << 31);
/**
* If CTxIn::nSequence encodes a relative lock-time and this flag
* is set, the relative lock-time has units of 512 seconds,
* otherwise it specifies blocks with a granularity of 1. */
static const uint32_t SEQUENCE_LOCKTIME_TYPE_FLAG = (1 << 22);
/**
* If CTxIn::nSequence encodes a relative lock-time, this mask is
* applied to extract that lock-time from the sequence field. */
static const uint32_t SEQUENCE_LOCKTIME_MASK = 0x0000ffff;
/**
* In order to use the same number of bits to encode roughly the
* same wall-clock duration, and because blocks are naturally
* limited to occur every 600s on average, the minimum granularity
* for time-based relative lock-time is fixed at 512 seconds.
* Converting from CTxIn::nSequence to seconds is performed by
* multiplying by 512 = 2^9, or equivalently shifting up by
* 9 bits. */
static const int SEQUENCE_LOCKTIME_GRANULARITY = 9;
CTxIn()
{
nSequence = SEQUENCE_FINAL;
}
explicit CTxIn(COutPoint prevoutIn, CScript scriptSigIn=CScript(), uint32_t nSequenceIn=SEQUENCE_FINAL);
CTxIn(Txid hashPrevTx, uint32_t nOut, CScript scriptSigIn=CScript(), uint32_t nSequenceIn=SEQUENCE_FINAL);
SERIALIZE_METHODS(CTxIn, obj) { READWRITE(obj.prevout, obj.scriptSig, obj.nSequence); }
friend bool operator==(const CTxIn& a, const CTxIn& b)
{
return (a.prevout == b.prevout &&
a.scriptSig == b.scriptSig &&
a.nSequence == b.nSequence);
}
friend bool operator!=(const CTxIn& a, const CTxIn& b)
{
return !(a == b);
}
std::string ToString() const;
};
출력(CTxOut)들
- nValue (CAmount)
- 전송되는 비트코인의 양
- satoshi 단위 (1 BTC = 100,000,000 satoshi)
- scriptPubKey (CScript)
- 출력을 사용하기 위한 조건을 지정하는 스크립트
- 예시
- P2PKH: “이 공개키의 소유자만 사용 가능”
- P2SH: “이 스크립트의 조건을 만족하면 사용 가능”
- P2WPKH: “이 세그윗 주소의 소유자만 사용 가능”
출력 소스
class CTxOut
{
public:
CAmount nValue;
CScript scriptPubKey;
CTxOut()
{
SetNull();
}
CTxOut(const CAmount& nValueIn, CScript scriptPubKeyIn);
SERIALIZE_METHODS(CTxOut, obj) { READWRITE(obj.nValue, obj.scriptPubKey); }
void SetNull()
{
nValue = -1;
scriptPubKey.clear();
}
bool IsNull() const
{
return (nValue == -1);
}
friend bool operator==(const CTxOut& a, const CTxOut& b)
{
return (a.nValue == b.nValue &&
a.scriptPubKey == b.scriptPubKey);
}
friend bool operator!=(const CTxOut& a, const CTxOut& b)
{
return !(a == b);
}
std::string ToString() const;
};
버전(version)
블록의 헤더와는 다르지만 용도는 동일해요. 프로토콜의 버전을 의미해요. 상위 버전은 하위 버전을 호환하되, 버전이 높을수록 더 많은 기능을 지원해요.
락타임(nLockTime)
락타임의 용도는 아래와 같아요.
- 시간 기준 (nLockTime >= 500000000)
- “이 거래는 2024년 1월 1일 이후에만 유효해”
- UNIX 타임스탬프로 지정
- 블록 높이 기준 (nLockTime < 500000000)
- “이 거래는 800000번 블록 이후에만 유효해”
- 특정 블록 높이로 지정
- 즉시 처리 (nLockTime = 0)
- “이 거래는 지금 바로 처리해도 돼”
- 대부분의 일반적인 거래