The implementation and test cases mentioned in this post can be found here.
The Trust Issue
When talking about accepting digital money, people usually have some basic concerns:
- Can I trust that the money is authentic and not counterfeit ?
- Can I trust that the money can only be spent once ?
- Can I trust that no one else can claim this money belongs to them and not me ?
Paper money address these concerns by its physical presence nature and advanced print technologies. For fiat money that is stored and transmitted digitally (eg. credit card, P2P payment apps, etc.), these issues are usually resolved by introducing central authorities that have a global ledger of the currency in circulation.
Same requirements apply to cryptocurrency like Bitcoin as well. If it fails either of them, it loses the trust. In this post, Iโm building a simplified version (yes, no fancy Merkle tree, Script, Proof-of-Work, etc.) of Bitcoin transaction verification system for a single node - with the end goal to fool it, and lose trust on it.
What does a Bitcoin transaction look like ?
The Bitcoin system works like a double-entry bookkeeping ledger, in which all the information about a money movement is encoded in a Bitcoin transaction . Most standard P2P transactions consist of inputs
and outputs
, where the former describes where the fund comes from and the latter describes where itโll end up at. Coinbase transaction is an exception that doesnโt have any inputs
because instead of transferred from a previous owner, itโs created by miners. Also, each transaction can be identified with a unique Transaction Identifier (TXID), which is the hash of raw transaction data.
enum Transaction {
case coinbase(id: ID, outputs: [Output])
case standard(id: ID, inputs: [Input], outputs: [Output])
}
Output
gives instructions on transferring the Bitcoin: to whom and how much.
struct Output {
let recipientAddress: Address
let value: Coins
}
Remember that in order for the double-entry ledger to neither lose money nor create money out of thin air, an Input
of a transaction must refer to the Output
of a previous transaction.
Hereโs how this connection is bridged. Whenever an Output
is generated, itโs โdumpedโ into a pool where it waits to be redeemd by the new owner. It maintains a temporary identity as Unspent Transaction Output (UTXO) during this grace period.
struct UnspentTransactionOutputID: Hashable {
let txID: ID
let txOutputIndex: Int
}
let utxoPool = [Transaction.UnspentTransactionOutputID: Transaction.Output]()
When the new owner is ready to redeem the money payable to her, she starts assembling her Input
that could be used to locate Output
in the pool.
struct Input {
let previousTxID: ID
let previousTxOutputIndex: Int
let signature: Signature
}
let utxoID = UnspentTransactionOutputID(txID: input.previousTxID, txOutputIndex: input.previousTxOutputIndex)
let utxo = utxoPool[utxoID]
But remember that this located utxo
can be spotted by everyone, not limited to the recipient. Then does that mean everyone can just pick it up from the pool and claim it as theirs? Good luck with that.
Signature
comes to the rescue. When an Output
is dumped to the pool with recipientAddress
, it follows such a protocol
Iโm payable to whomever can present a signature from the key corresponding to this public address
Say if Output.recipientAddress
points to Alice, Bob can never claim it either with his own signature, or a fake Aliceโs signature.
How does a node verify Bitcoin transactions ?
With discussions above and the concerns thrown out at the begining, we could derive some basic principles to validate a transacion:
- Input must be in current UTXO pool.
- Input signature must be valid.
- Same UTXO canโt be redeemd multiple times.
- Total input value must not be less than total output value.
Based on these principles, we can try building that simplified version of Bitcoin transaction verification system.
Letโs break things.
Things only go well when they go well. Assume Alice ๐ฉ๐ปโ๐พ and Bob ๐ฆน๐ปโโ๏ธ are usually good citizens, but sometimes they can be bad actors as well.
Case 1. ๐ฉ๐ปโ๐พ and ๐ฆน๐ปโโ๏ธ both get initial ๐ฐ๐ฐ๐ฐ from a coinbase transaction
let initialCoins: Transaction = .coinbase(
id: "๐ฎ",
outputs: [
.init(recipientAddress: "๐ฉ๐ปโ๐พ๐ฎ", value: "๐ฐ๐ฐ๐ฐ"),
.init(recipientAddress: "๐ฆน๐ปโโ๏ธ๐ฎ", value: "๐ฐ๐ฐ๐ฐ"),
]
)
Everything just works! Now each of them owns some money.
๐ค hooray your transaction is validated!
Current UTXO pool:
๐ฎ(1) | owner: ๐ฆน๐ปโโ๏ธ | amount: ๐ฐ๐ฐ๐ฐ
๐ฎ(0) | owner: ๐ฉ๐ปโ๐พ | amount: ๐ฐ๐ฐ๐ฐ
Case 2. ๐ฉ๐ปโ๐พ pays ๐ฐ๐ฐ to ๐ฆน๐ปโโ๏ธ
She locates the (only) previous transaction payable to her (identified as โ๐ฎโ), redeems it by verifying her signature - so far she has proved ๐ฐ๐ฐ๐ฐ is hers, and she can do whatever she wants with them. She sends ๐ฐ๐ฐ to ๐ฆน๐ปโโ๏ธ and sends the remaining ๐ฐ back to herself as change.
let realAlicePayBob: Transaction = .standard(
id: "๐น",
inputs: [
.init(
previousTxID: "๐ฎ",
previousTxOutputIndex: 0,
signature: "๐ฉ๐ปโ๐พ๐๐น"
),
],
outputs: [
.init(recipientAddress: "๐ฆน๐ปโโ๏ธ๐ฎ", value: "๐ฐ๐ฐ"),
.init(recipientAddress: "๐ฉ๐ปโ๐พ๐ฎ", value: "๐ฐ"), // change
]
)
Great, now ๐ฉ๐ปโ๐พ only has ๐ฐ and ๐ฆน๐ปโโ๏ธ has ๐ฐ๐ฐ๐ฐ๐ฐ๐ฐ in total.
๐ค hooray your transaction is validated!
Current UTXO pool:
๐น(0) | owner: ๐ฆน๐ปโโ๏ธ | amount: ๐ฐ๐ฐ
๐น(1) | owner: ๐ฉ๐ปโ๐พ | amount: ๐ฐ
๐ฎ(1) | owner: ๐ฆน๐ปโโ๏ธ | amount: ๐ฐ๐ฐ๐ฐ
Case 3. ๐ฆน๐ปโโ๏ธ counterfeits ๐ฐ๐ฐ๐ฐ out of thin air
Unfortunately, heโs too lazy to do due diligence so just picks up a random transction ID ๐ฅ to locate previous transaction, which never exists.
let bobDayDream: Transaction = .standard(
id: "๐ธ",
inputs: [
.init(
previousTxID: "๐ฅ", // never exists in UTXO pool
previousTxOutputIndex: 1,
signature: "๐ฆน๐ปโโ๏ธ๐๐ธ"
),
],
outputs: [
.init(recipientAddress: "๐ฆน๐ปโโ๏ธ๐ฎ", value: "๐ฐ๐ฐ๐ฐ"),
]
)
Unsurprisingly, his transaction is denied.
๐ uh oh your transaction is denied.
Reason: Input not found in UTXO pool
Current UTXO pool:
๐น(0) | owner: ๐ฆน๐ปโโ๏ธ | amount: ๐ฐ๐ฐ
๐น(1) | owner: ๐ฉ๐ปโ๐พ | amount: ๐ฐ
๐ฎ(1) | owner: ๐ฆน๐ปโโ๏ธ | amount: ๐ฐ๐ฐ๐ฐ
Case 4. ๐ฆน๐ปโโ๏ธ counterfeits ๐ฐ paid by ๐ฉ๐ปโ๐พ
This time he gets smarter. He looks up a previous existing transaction ๐น payable to ๐ฉ๐ปโ๐พ, fakes a signature and put himself as recipient.
let fakeAlicePayBob: Transaction = .standard(
id: "๐บ",
inputs: [
.init(
previousTxID: "๐น",
previousTxOutputIndex: 1,
signature: "๐ท๐ปโโ๏ธ๐๐บ" // fake ๐ฉ๐ปโ๐พ signature signed by ๐ฆน๐ปโโ๏ธ
),
],
outputs: [
.init(recipientAddress: "๐ฆน๐ปโโ๏ธ๐ฎ", value: "๐ฐ"),
]
)
Butโฆtransaction ๐น is payable to ๐ฉ๐ปโ๐พ, so only her signature could redeem it.
๐ uh oh your transaction is denied.
Reason: Invalid input signature
Current UTXO pool:
๐น(0) | owner: ๐ฆน๐ปโโ๏ธ | amount: ๐ฐ๐ฐ
๐น(1) | owner: ๐ฉ๐ปโ๐พ | amount: ๐ฐ
๐ฎ(1) | owner: ๐ฆน๐ปโโ๏ธ | amount: ๐ฐ๐ฐ๐ฐ
Case 5. ๐ฉ๐ปโ๐พ tries paying ๐ฐ๐ฐ to ๐ฆน๐ปโโ๏ธ by redeeming ๐ฐ twice
She needs to pay ๐ฆน๐ป ๐ฐ๐ฐ, but she only owns ๐ฐnow - so sheโs gonna try her luck by redeeming same UTXO payable to her multiple times.๐ฐx 2 = ๐ฐ๐ฐ, what a beautiful math.
let alicePayBobDoubleSpend: Transaction = .standard(
id: "๐ฅ",
inputs: [
.init(
previousTxID: "๐น",
previousTxOutputIndex: 1,
signature: "๐ฉ๐ปโ๐พ๐๐ฅ"
),
.init(
previousTxID: "๐น",
previousTxOutputIndex: 1,
signature: "๐ฉ๐ปโ๐พ๐๐ฅ"
),
],
outputs: [
.init(recipientAddress: "๐ฆน๐ปโโ๏ธ๐ฎ", value: "๐ฐ๐ฐ"),
]
)
โSame UTXO redeemd multiple times in inputsโโฆBummer.
Try adding a transaction...
๐ uh oh your transaction is denied.
Reason: Same UTXO redeemd multiple times in inputs
Current UTXO pool:
๐น(0) | owner: ๐ฆน๐ปโโ๏ธ | amount: ๐ฐ๐ฐ
๐น(1) | owner: ๐ฉ๐ปโ๐พ | amount: ๐ฐ
๐ฎ(1) | owner: ๐ฆน๐ปโโ๏ธ | amount: ๐ฐ๐ฐ๐ฐ
Case 6. ๐ฆน๐ปโโ๏ธ tries aggregating ๐ฐ๐ฐ๐ฐ + ๐ฐ๐ฐ into ๐ฐ๐ฐ๐ฐ๐ฐ๐ฐ๐ฐ
He just loses faith that ๐ฉ๐ปโ๐พ could figure out a way to pay him, so he decides to try something creative again. โWhat if I just aggregate all my funds, and secretly add a little more to it?โ
let bobAggregateChangesMoreThanHeOwn: Transaction = .standard(
id: "๐ป",
inputs: [
.init(
previousTxID: "๐ฎ",
previousTxOutputIndex: 1,
signature: "๐ฆน๐ปโโ๏ธ๐๐ป"
),
.init(
previousTxID: "๐น",
previousTxOutputIndex: 0,
signature: "๐ฆน๐ปโโ๏ธ๐๐ป"
),
],
outputs: [
.init(recipientAddress: "๐ฆน๐ปโโ๏ธ๐ฎ", value: "๐ฐ๐ฐ๐ฐ๐ฐ๐ฐ๐ฐ"),
]
)
OK, fair enough.
๐ uh oh your transaction is denied.
Reason: Total input ๐ฐ๐ฐ๐ฐ๐ฐ๐ฐ is less than total output ๐ฐ๐ฐ๐ฐ๐ฐ๐ฐ๐ฐ
Current UTXO pool:
๐น(0) | owner: ๐ฆน๐ปโโ๏ธ | amount: ๐ฐ๐ฐ
๐น(1) | owner: ๐ฉ๐ปโ๐พ | amount: ๐ฐ
๐ฎ(1) | owner: ๐ฆน๐ปโโ๏ธ | amount: ๐ฐ๐ฐ๐ฐ
It feels like being a bad actor is way much harder than being a good citizen?