블록체인/메인넷

Go 언어로 블록체인 메인넷 만들기 - 거래#1

이번 포스트에서는 거래를 다룬다. 이전 포스트에서 트랜잭션을 이야기하면서 유일한 거래를 만든 것이 바로 코인베이스 트랜잭션인데, 코인베이스 트랜잭션은 입력 값이 없기 때문에 일반적인 거래라고 보기는 어렵다. 따라서 이번 포스트에서는 실제 거래처럼 두 주체가 대금을 지불하고, 잔액을 돌려주는 것을 해볼 것이다.

UTXO(Unspent Transaction Output)

UTXO, 즉 소비되지 않은 거래 출력 값은 거래에서 반드시 이해해야 하는 개념이다. 처음 이 개념을 접한다면 이해하기 어려울 수도 있다. 나의 경우는 이 개념을 이해하기 위해 다소 오랜 시간이 걸렸다. 먼저 소비(Spent)에 대해 생각해보자. 소비라는 것은 내가 가지고 있는 자금을 다른 주체에게 지불하는 행위를 말한다. 소비를 하려면 먼저 내가 가지고 있는 자금 현황에 대해 파악해야 한다.

 

그렇다면 내가 가지고 있는 자금은 어떻게 얻을 수 있는가? 내가 가지고 있는 자금(Balance)이란 나의 공개키(Public Key)로 묶여있는 TXOutput 을 말한다. 그리고 한 가지 더, 그러한 TXOutput다른 거래의 TXInput 에 참조된 적이 없어야 한다. 즉, 다른 거래에서 해당 출력값을 자금의 원천으로 사용하고 지불하기 위해 사용된 적이 없어야 한다는 의미다.

 

정리하자면 나의 공개키로 묶여있으며 다른 트랜잭션에 참조된 적이 없는 트랜잭션 출력 값의 합이 내가 가지고 있는 총 자금이다. 우리는 이것을 UTXO(Unspent Transaction Output) 라고 한다. 따라서 우리는 다른 사람에게 돈을 지불하기 전에 내가 가지고 있는 자금을 먼저 파악해야 한다.

func Blockchain.FindUnspentTransactions(string) []*Transaction

UTXO 를 찾는 과정은 복잡해보이지만 알고보면 단순하다. 맨 마지막 블록에서 역순으로 제네시스 블록까지 진행하여 모든 TXOutput 에서 TXInput 에 참조된 적이 있는, 즉, 입력값이 없는 코인베이스 트랜잭션을 제외한 트랜잭션에 대해 TXInput 을 조사하여 소비(Spent)된 트랜잭션 출력(Spent Transaction Outputs) 집합을 찾고 체인을 따라가며 이미 소비된 트랜잭션 집합을 제외하게 되면 소비되지 않은 트랜잭션 집합, UTXO 를 찾을 수 있다.

func (bc *Blockchain) FindUnspentTransactions(address string) []*Transaction {
	bci := NewBlockchainIterator(bc)

	spentTXOs := make(map[string][]int)
	var unspentTXs []*Transaction

	for bci.HasNext() {
		for _, tx := range bci.Next().Transactions {
			txID := hex.EncodeToString(tx.ID)

		Outputs:
			for outIdx, out := range tx.Vout {
				// TXOutput 에서 이미 소비된 트랜잭션에 대해서는 처리하지 않는다.
				if spentTXOs[txID] != nil {
					for _, spentOut := range spentTXOs[txID] {
						if spentOut == outIdx {
							continue Outputs
						}
					}
				}
				// address 의 공개키로 출력이 되었다는 것은 address 에게 자금을 보냈다는 이야기다.
				// 그 이외의 트랜잭션은 아직 소비되지 않은 트랜잭션이다.
				if out.ScriptPubKey == address {
					unspentTXs = append(unspentTXs, tx)
				}
			}

			// 입력이 없는 코인베이스 트랜잭션은 제외.
			if !tx.IsCoinbase() {
				// TXInput 을 조사하여 이미 소비된 출력 집합을 얻는다.
				for _, in := range tx.Vin {
					// 서명을 address 가 했음은 address 가 지불을 위해
					// 해당 트랜잭션 출력을 사용했다는 뜻이다.
					if in.ScriptSig == address {
						hash := hex.EncodeToString(in.Txid)
						spentTXOs[hash] = append(spentTXOs[hash], in.Vout)
					}
				}
			}
		}
	}

	return unspentTXs
}

여기서 눈여서 봐야하는 점은 out.ScriptPubKey, in.ScriptSig 에 대해 조건문을 걸어주는 부분이다. 이 부분이 아주 중요하다. 현재 공개키(Public Key)개인키(Private Key)가 없는 상황에서 address 파라매터는 Ivan, Jane 과 같이 이름으로 들어올 것이다. 예를 들어 addressIvan 이라는 값이라고 가정했을 때, out.ScriptPubKeyIvan 이라는 것은 어떤 트랜잭션에서 Ivan 에게 자금을 보냈다는 것이다. 반대로 in.ScriptSigIvan 인 경우, 어떤 거래에서 자금을 소비(Spent)하기 위해 Ivan 의 이름으로 서명을 했다는 이야기다. 이 부분은 몇 번을 강조해도 모자랄 정도로 중요한 부분이다.

func Transaction.IsCoinbase() bool

이 메서드는 해당 트랜잭션이 코인베이스 트랜잭션인지 반환한다. 거래를 처리할 때 코인베이스 트랜잭션에 입력 값이 존재하지 않기 때문에 걸러줘야 할 부분이 있기 때문이다.

func (tx *Transaction) IsCoinbase() bool {
	return bytes.Compare(tx.Vin[0].Txid, []byte{}) == 0 && tx.Vin[0].Vout == -1 && len(tx.Vin) == 1
}

func Blockchain.FindUTXO(string) []TXOutput

Blockchain.FindUnpentTransactions() 가 반환하는 것은 []*Transaction 이기 때문에 특정 주소가 가진 자금을 구하기 위해서는 []TXOutput 을 얻어올 필요가 있기 때문에 별도의 메서드로 분리한다.

func (bc *Blockchain) FindUTXO(address string) []TXOutput {
	var UTXOs []TXOutput
	unspentTXs := bc.FindUnspentTransactions(address)

	for _, tx := range unspentTXs {
		for _, out := range tx.Vout {
			if out.ScriptPubKey == address {
				UTXOs = append(UTXOs, out)
			}
		}
	}

	return UTXOs
}

func Blockchain.GetBalance(string) uint64

이제 드디어 특정 주소가 가지고 있는 자금의 총 합을 구할 수 있게되었다. 통장에 있는 계좌잔액을 확인하듯이, 현재 나의 자금으로 되어있는 것들(UTOX)가 가진 코인(Value)의 합을 구하기만 하면 된다.

func (bc *Blockchain) GetBalance(address string) uint64 {
	var balance uint64

	for _, out := range bc.FindUTXO(address) {
		balance += out.Value
	}

	return balance
}

func Blockchain.Send(uint64, string, string, *Blockchain) *Transaction

이제 우리는 거래를 만들 수 있다. 현재 가지고 있는 자금을 추적하고, 현재 자금보다 보내는 자금이 더 큰 경우 실행을 중지하면 될 것이고, 충분하다면 금액을 보낸뒤 잔액(Change)를 만들면 된다. 거래에서 가장 중요한 개념은 코인은 화폐처럼 분리할 수 없다는 점이다.

 

천원짜리 화폐는 수학적으로 100원짜리 10개로 대체될 수 있지만, 물리적으로는 분할될 수 없다. 블록체인에서 코인도 똑같이 작동한다. 코인은 덩어리이기 때문에 여러 개로 분리될 수 없으며 사용하려면 모두 소비한 뒤 잔액을 받아야하는 것이다. 마치 우리가 상점에서 3만원 짜리를 살 때 5만원을 지불하고 2만원의 잔금을 받는 것과 같다.

 

거래를 구성하려면 먼저 TXInput 을 구성해야만 한다. 작은 코인뭉치를 조합하거나, 하나의 큰 코인뭉치를 사용할 수 있을 것이다. UTXO 를 찾은 다음, 가지고 있는 코인뭉치를 더해가면서 보내려는 자금보다 커지면 더 이상 계산할 필요가 없기 때문에 반복문을 중단하고, 만약 모든 자금을 더 더했음에도 불구하고 보내려는 자금보다 적다면 실행을 중단해야 한다.

func (bc *Blockchain) Send(value uint64, from, to string, bc *Blockchain) *Transaction {
	var txin []TXInput
	var txout []TXOutput

	UTXs := bc.FindUnspentTransactions(from)
	var acc uint64

Work:
	for _, tx := range UTXs {
		for outIdx, out := range tx.Vout {
			if out.ScriptPubKey == from && acc < value {
				acc += out.Value
				txin = append(txin, TXInput{tx.ID, outIdx, from})
			}
			if acc >= value {
				break Work
			}
		}
	}

	if value > acc {
		log.Panic("ERROR: NOT enough funds")
	}

	txout = append(txout, TXOutput{value, to})
	if acc > value {
		txout = append(txout, TXOutput{acc - value, from})
	}

	return NewTransaction(txin, txout)
}

TXOutput 의 출력 값은 두 개다. 보내려는 자금은 상대방의 공개키로 잠그고, 나머지 하나는 잔액인데, 이것은 나의 공개키로 잠그는 것이다. 내 공개키로 잠겨있는 것이 내가 가지고 있는 자금이니까 말이다.

 

일반적으로 지갑 어플리케이션은 거래를 만들 수 있는데, 이러한 거래를 구성할 때 코인 덩어리를 합칠 때도 전략에 따라 달리 할 수 있을 것이다. 자신이 가지고 있는 UTXO 중에서 가치가 큰 것을 먼저 사용하게 할 수도 있고, 작은 것들을 조합해서 사용하게 할 수도 있다. 한 가지 주의해야 할 점은, 거래의 복잡도가 증가할 수록 나중에 이야기하게 될 트랜잭션 수수료(Fee)가 증가하게 된다는 점이다.

type CLI

이제 우리가 만든 것들을 실행해볼 시간이 왔다. 참고로 우리는 블록 하나당 트랜잭션을 한 개만 가지게 될 예정이다. 따라서 addblock 명령어는 날려도 상관없다.

func .send(uint64, string, string)

해당 명령은 코인을 다른이에게 전송한다. 이 경우 블록은 채굴되지만 Coinbase Transaction 을 지정해주지 않았기 때문에 보상은 주어지지 않는다는 점을 주목하자.

func (c *CLI) send(value uint64, from, to string) {
	bc := NewBlockchain()
	defer bc.db.Close()

	tx := bc.Send(value, from, to)
	bc.AddBlock([]*Transaction{tx})
}

func .getBalance(string)

특정 주소의 자금 현황을 볼 수 있다. 이제는 이 명령어가 특정 주소의 UTXO 의 합을 보여준다는 것을 알고 있을 것이다.

func (c *CLI) getBalance(address string) uint64 {
	bc := NewBlockchain()
	defer bc.db.Close()

	return bc.GetBalance(address)
}

func .Run()

놀랍게도 대부분의 명령어가 추가되었거나 변경되었기 때문에 생략할 부분은 .list() 뿐이다. newCmd 의 주소 값 받기, send, getbalance 명령어의 추가가 주요 변경점이다.

func (c *CLI) Run() {
	// ...
	newCmd := flag.NewFlagSet("new", flag.ExitOnError)
	sendCmd := flag.NewFlagSet("send", flag.ExitOnError)
	getBalanceCmd := flag.NewFlagSet("getbalance", flag.ExitOnError)

	sendValue := sendCmd.Uint64("value", 0, "")
	sendFrom := sendCmd.String("from", "", "")
	sendTo := sendCmd.String("to", "", "")

	getBalanceAddress := getBalanceCmd.String("address", "", "")

	switch os.Args[1] {
 	// ...
	case "new":
		newCmd.Parse(os.Args[2:])
	case "send":
		sendCmd.Parse(os.Args[2:])
	case "getbalance":
		getBalanceCmd.Parse(os.Args[2:])
	}

	// ...
	if newCmd.Parsed() {
		if *newAddress == "" {
			newCmd.Usage()
			os.Exit(1)
		}
		c.createBlockchain(*newAddress)
	}
	if sendCmd.Parsed() {
		if *sendValue == 0 || *sendFrom == "" || *sendTo == "" {
			sendCmd.Usage()
			os.Exit(1)
		}
		c.send(*sendValue, *sendFrom, *sendTo)
	}
	if getBalanceCmd.Parsed() {
		if *getBalanceAddress == "" {
			getBalanceCmd.Usage()
			os.Exit(1)
		}
		fmt.Printf("Balance of '%s': %d\n", *getBalanceAddress, c.getBalance(*getBalanceAddress))
	}
}

결론

지금까지 만든 것들을 한 번 테스트해보도록 하자. 총 코인 발행량은 블록체인을 완전히 새로 만들때 발행한 10 에 해당하며 이를 기반으로 코인을 주고받고 할 수 있는 것을 볼 수 있다. 총 코인 발행량은 곧 주소에 관계없이 모든 UTXO 의 총 합일 것이다.

# 'Ivan' 이 새로운 블록체인을 만들고 10 의 보상을 받는다.
# 총 코인 발행량은 10
$ ./bc new -address Ivan
0000ffb1228d778057974732ae61378216481dba8ef8bd3b2d2b7bc0c9ef8f21

$ ./bc getbalance -address Ivan
Balance of 'Ivan': 10

$ ./bc getbalance -address Jane
Balance of 'Jane': 0

# 'Jane' 에게 'Ivan' 이 5 만큼의 코인을 보냄.
$ ./bc send -from Ivan -to Jane -value 5
000006a1d0802bebadedd2b5c22662387efcd2db7950ff5d6ac42c5e61ac2fee

$ ./bc getbalance -address Ivan
Balance of 'Ivan': 5

$ ./bc getbalance -address Jane
Balance of 'Jane': 5

# 'Jane' 이 'Joe' 에게 3 만큼의 코인을 보냄.
$ ./bc send -from Jane -to Joe -value 3
00008e1e349f1eeaacb0611f39f0b1104bdbe6fbf8d6854f19dd80c9ca0dd087

$ ./bc getbalance -address Ivan
Balance of 'Ivan': 5

$ ./bc getbalance -address Jane
Balance of 'Jane': 2

$ ./bc getbalance -address Joe
Balance of 'Joe': 3

# 'Ivan' 이 'Joe' 에게 7 만큼을 보내려고 시도하지만 잔액 부족으로 실패.
$ ./bc send -from Ivan -to Joe -value 7
2021/04/09 17:00:28 ERRPR: NOT enough funds
panic: ERROR: NOT enough funds

지금까지 트랜잭션의 간단한 부분을 살펴봤다. 사실 아직 out.ScriptPubKey, in.ScriptSig 에 대한 내용이 남아있고, 주소도 제대로 만들어주어야 하기 때문에 여전히 갈길은 멀지만, 포스트 상으로는 얼마 남지 않았다.