How to sign a Bitcoin transaction
Edited: 02/23/2023
Introduction
In the last blog post, we saw how to generate a bitcoin address on the command line. This was a base58-encoded address which was used to receive bitcoin in a pay-to-pubkey-hash transaction.
In this post, you’ll see how to create a transaction to send bitcoins to another address and sign it. The examples below will take place on bitcoin testnet.
Pre-requisites
To follow along you may need the following tools
Bitcoin Core
We’ll make use of bitcoin-cli
configured for rpc interaction with a local bitcoind
node
OpenSSL
openssl
comes pre-installed on most systems
python-ecdsa
As an alternative to signing transactions with bitcoin-cli
and openssl
, we’ll make use of this python library.
Create a raw transaction
I have a little bit of tBTC (testnet Bitcoin) at the address mwDfF3Ukg81aV6ngxBQvTZWTK2ftj1Fr4T
, as can be seen by the following command.
bitcoin-cli scantxoutset start '["addr(mwDfF3Ukg81aV6ngxBQvTZWTK2ftj1Fr4T)"]'
Output:
{
"success": true,
"txouts": 28387067,
"height": 2417653,
"bestblock": "000000000000001c38720386eb2c9e1e5e4747cea424abe8264da9fd833614be",
"unspents": [
{
"txid": "abf18fe8e1554f2d3b9826ec12de4bfc7d7ce5e04b9e4c1056c35dea54bc9727",
"vout": 0,
"scriptPubKey": "76a914ac3cb4fc6ee6663e697c743aad0d89fd8eb4c15f88ac",
"desc": "addr(mwDfF3Ukg81aV6ngxBQvTZWTK2ftj1Fr4T)#8ax3vmec",
"amount": 0.00899000,
"height": 2412385
}
],
"total_amount": 0.00899000
}
How do we send these bitcoins to another address?
Well, I can create a raw transaction using bitcoin-cli
bitcoin-cli createrawtransaction "[{\"txid\": \"abf18fe8e1554f2d3b9826ec12de4bfc7d7ce5e04b9e4c1056c35dea54bc9727\", \"vout\": 0}]" "[{\"n3sSFbhzzefkutYmMCmzi26o5ECtbb8mCt\": 0.00898}]"
Output:
02000000012797bc54ea5dc356104c9e4be0e57c7dfc4bde12ec26983b2d4f55e1e88ff1ab0000000000fdffffff01d0b30d00000000001976a914f5326ca3988fd949f3b7699922caf3eadc86e71488ac00000000
In the createrawtransaction
command above, notice I’ve used the txid
and vout
obtained from the scantxoutset
command I ran prior. I’ve also specified the address (n3sSFbhzzefkutYmMCmzi26o5ECtbb8mCt
) for the output and included a 1,000 satoshi miner fee.
(Note: In Bitcoin, unspent transaction outputs (UTXO) are always spent in full, and any amount not specified in a transaction output is assumed to be the miner fee.)
Now we need to sign the transaction.
With bitcoin-cli
we can leverage the signrawtransactionwithkey
method. To use this, we’ll need to provide our private key in base58 wallet import format (WIF). To do that, first retrieve the key in hex format. Assuming our secret key is located in secret.pem
,
openssl asn1parse -in secret.pem
Output:
0:d=0 hl=2 l= 116 cons: SEQUENCE
2:d=1 hl=2 l= 1 prim: INTEGER :01
5:d=1 hl=2 l= 32 prim: OCTET STRING [HEX DUMP]:56C2FE62B27107CC9ADFE6CE1D919E04C356B3A7D3B518F70C28C08480645AAB
39:d=1 hl=2 l= 7 cons: cont [ 0 ]
41:d=2 hl=2 l= 5 prim: OBJECT :secp256k1
48:d=1 hl=2 l= 68 cons: cont [ 1 ]
50:d=2 hl=2 l= 66 prim: BIT STRING
Grab the hex string shown on the third line. This is the private key, i.e.
56C2FE62B27107CC9ADFE6CE1D919E04C356B3A7D3B518F70C28C08480645AAB
To convert this to base58-encoded WIF, first prepend hex 80
for mainnet or ef
for testnet, e.g.
EF56C2FE62B27107CC9ADFE6CE1D919E04C356B3A7D3B518F70C28C08480645AAB
And append 01
if the private key is for a compressed public key (which this key is)
EF56C2FE62B27107CC9ADFE6CE1D919E04C356B3A7D3B518F70C28C08480645AAB01
Finally, encode as base58check.
echo $(echo EF56C2FE62B27107CC9ADFE6CE1D919E04C356B3A7D3B518F70C28C08480645AAB01 | xxd -r -p | base58 -c)
Output:
cQVMYbVNK1i6JfJm6QyvK9JBo3JxxoQq1Q4hejckbttq8V6X7wvN
Now with bitcoin-cli
bitcoin-cli signrawtransactionwithkey 02000000012797bc54ea5dc356104c9e4be0e57c7dfc4bde12ec26983b2d4f55e1e88ff1ab0000000000fdffffff01d0b30d00000000001976a914f5326ca3988fd949f3b7699922caf3eadc86e71488ac00000000 '["cQVMYbVNK1i6JfJm6QyvK9JBo3JxxoQq1Q4hejckbttq8V6X7wvN"]'
Output:
{
"hex": "02000000012797bc54ea5dc356104c9e4be0e57c7dfc4bde12ec26983b2d4f55e1e88ff1ab000000006a4730440220243f86753f140847b7aef58f1724cd3b9b2699a5ea860018a9066da2cfb97b53022042469edad507502a24c81bc57e637e180fb3bf81094e82beac4ee21f795eec1c012102726b45a5b1b506015dc926630b2627454d635d87eeb72bb7d5476d545d6769f9fdffffff01d0b30d00000000001976a914f5326ca3988fd949f3b7699922caf3eadc86e71488ac00000000",
"complete": true
}
And we can verify this transaction, without actually sending it with
bitcoin-cli testmempoolaccept '["02000000012797bc54ea5dc356104c9e4be0e57c7dfc4bde12ec26983b2d4f55e1e88ff1ab000000006a4730440220243f86753f140847b7aef58f1724cd3b9b2699a5ea860018a9066da2cfb97b53022042469edad507502a24c81bc57e637e180fb3bf81094e82beac4ee21f795eec1c012102726b45a5b1b506015dc926630b2627454d635d87eeb72bb7d5476d545d6769f9fdffffff01d0b30d00000000001976a914f5326ca3988fd949f3b7699922caf3eadc86e71488ac00000000"]'
Output:
[
{
"txid": "723b4eba8757701244247b14aea4a12b02c278e5447b12f85495445f67c5c749",
"wtxid": "723b4eba8757701244247b14aea4a12b02c278e5447b12f85495445f67c5c749",
"allowed": true,
"vsize": 191,
"fees": {
"base": 0.00001000
}
}
]
Cool. 😎
Now let’s see how we can sign transactions with other tools but still verify them with bitcoin-cli
.
Signing with openssl
So we have the raw transaction, i.e.
02000000012797bc54ea5dc356104c9e4be0e57c7dfc4bde12ec26983b2d4f55e1e88ff1ab0000000000fdffffff01d0b30d00000000001976a914f5326ca3988fd949f3b7699922caf3eadc86e71488ac00000000
First, let’s take a look at the transaction data structure.
The following shows the transaction data with description for each part. Most values are seen as hex-encoded little endian bytes.
02000000 # tx version 2
01 # number of tx inputs
# txin1 txid
2797bc54ea5dc356104c9e4be0e57c7dfc4bde12ec26983b2d4f55e1e88ff1ab
00000000 # txin1 output number
00 # txin1 scriptsig length
fdffffff # txin1 sequence number
01 # number of tx outputs
d0b30d0000000000 # value
19 # txout1 scriptpubkey length
# txout1 scriptpubkey
76a914f5326ca3988fd949f3b7699922caf3eadc86e71488ac
00000000 # tx locktime
Internal Byte Order
You may notice that the txid seen in the above transaction (2797bc54ea5dc356104c9e4be0e57c7dfc4bde12ec26983b2d4f55e1e88ff1ab
) doesn’t look quite the same as that seen in the scantxoutset
command at the beginning of this blog (abf18fe8e1554f2d3b9826ec12de4bfc7d7ce5e04b9e4c1056c35dea54bc9727
). This is because the latter returns the txid in rpc byte order whereas the former is referred to as internal byte order. The difference essentially amounts to a reversal of the byte order. We can convert from rpc byte order to internal byte order with the following python code.
>>> txid = bytearray(bytes.fromhex("abf18fe8e1554f2d3b9826ec12de4bfc7d7ce5e04b9e4c1056c35dea54bc9727"))
>>> txid.reverse()
>>> txid.hex()
'2797bc54ea5dc356104c9e4be0e57c7dfc4bde12ec26983b2d4f55e1e88ff1ab'
Preparing the signature data
One aspect that bitcoin-cli
conceals when using signrawtransactionwithkey
is that each input’s outpoint’s scriptPubKey is actually used as its scriptSig while signing.
So we’re not actually signing the above transaction exactly; we’ll need to insert the previous outpoint’s scriptPubKey first.
If you recall, that information was provided in the output from an earlier command, e.g.
bitcoin-cli scantxoutset start '["addr(mwDfF3Ukg81aV6ngxBQvTZWTK2ftj1Fr4T)"]'
Output:
{
"success": true,
"txouts": 28387067,
"height": 2417653,
"bestblock": "000000000000001c38720386eb2c9e1e5e4747cea424abe8264da9fd833614be",
"unspents": [
{
"txid": "abf18fe8e1554f2d3b9826ec12de4bfc7d7ce5e04b9e4c1056c35dea54bc9727",
"vout": 0,
"scriptPubKey": "76a914ac3cb4fc6ee6663e697c743aad0d89fd8eb4c15f88ac",
"desc": "addr(mwDfF3Ukg81aV6ngxBQvTZWTK2ftj1Fr4T)#8ax3vmec",
"amount": 0.00899000,
"height": 2412385
}
],
"total_amount": 0.00899000
}
Including this in our raw transaction, and remembering to add the script length as hex 19
(encoded as a compact size uint), the serialization becomes
02000000012797bc54ea5dc356104c9e4be0e57c7dfc4bde12ec26983b2d4f55e1e88ff1ab000000001976a914ac3cb4fc6ee6663e697c743aad0d89fd8eb4c15f88acfdffffff01d0b30d00000000001976a914f5326ca3988fd949f3b7699922caf3eadc86e71488ac00000000
Before signing, we append SIGHASH_ALL
(0x01
) encoded as 4-bytes, little-endian.
02000000012797bc54ea5dc356104c9e4be0e57c7dfc4bde12ec26983b2d4f55e1e88ff1ab000000001976a914ac3cb4fc6ee6663e697c743aad0d89fd8eb4c15f88acfdffffff01d0b30d00000000001976a914f5326ca3988fd949f3b7699922caf3eadc86e71488ac0000000001000000
Then we take the double sha256 hash of this and sign it with openssl
as follows.
echo 02000000012797bc54ea5dc356104c9e4be0e57c7dfc4bde12ec26983b2d4f55e1e88ff1ab000000001976a914ac3cb4fc6ee6663e697c743aad0d89fd8eb4c15f88acfdffffff01d0b30d00000000001976a914f5326ca3988fd949f3b7699922caf3eadc86e71488ac0000000001000000 | xxd -r -p | openssl sha256 | xxd -r -p | openssl sha256 -sign secret.pem | xxd -p -c 1000
Output:
3045022100df741c554ee34ab636a213956abf85fd4d05143026eb7b5f07ba56ac8978687902207c7e70f5790c6146599dd7c035111b93f131d95591b6030c80b1010d2e047ed3
Note: The signing algorithm is not deterministic and will result in different values each time for the same signature data and key
Hint: You can verify signatures with openssl
using the -verify
flag. E.g.
openssl sha256 -verify [file] -signature [file] [file ...]
Finally, we replace / insert in the original transaction, the correct scriptSig, which for pay-to-pubkeyhash transactions is denoted in Bitcoin Script as
<sig> <pubkey>
Implied in the above Script are data pushes for the <sig>
and <pubkey>
, respectively. Also, keep in mind that <sig>
includes a single byte for the SIGHASH flag appended to it.
With the mwDfF3Ukg81aV6ngxBQvTZWTK2ftj1Fr4T
address corresponding to a compressed pubkey of 02726b45a5b1b506015dc926630b2627454d635d87eeb72bb7d5476d545d6769f9
, the script sig becomes.
483045022100df741c554ee34ab636a213956abf85fd4d05143026eb7b5f07ba56ac8978687902207c7e70f5790c6146599dd7c035111b93f131d95591b6030c80b1010d2e047ed3012102726b45a5b1b506015dc926630b2627454d635d87eeb72bb7d5476d545d6769f9
This entire scriptSig accounts for a length of 0x6b
. With this, the transaction becomes
02000000012797bc54ea5dc356104c9e4be0e57c7dfc4bde12ec26983b2d4f55e1e88ff1ab000000006b483045022100df741c554ee34ab636a213956abf85fd4d05143026eb7b5f07ba56ac8978687902207c7e70f5790c6146599dd7c035111b93f131d95591b6030c80b1010d2e047ed3012102726b45a5b1b506015dc926630b2627454d635d87eeb72bb7d5476d545d6769f9fdffffff01d0b30d00000000001976a914f5326ca3988fd949f3b7699922caf3eadc86e71488ac00000000
Let’s test it with `testmempoolaccept’
bitcoin-cli testmempoolaccept '["02000000012797bc54ea5dc356104c9e4be0e57c7dfc4bde12ec26983b2d4f55e1e88ff1ab000000006b483045022100df741c554ee34ab636a213956abf85fd4d05143026eb7b5f07ba56ac8978687902207c7e70f5790c6146599dd7c035111b93f131d95591b6030c80b1010d2e047ed3012102726b45a5b1b506015dc926630b2627454d635d87eeb72bb7d5476d545d6769f9fdffffff01d0b30d00000000001976a914f5326ca3988fd949f3b7699922caf3eadc86e71488ac00000000"]'
Output:
[{'txid': '766a1be97eed0bc3f3c1e350f1c4cc2c0a634f676bedcc7cc116379f619abec6', 'wtxid': '766a1be97eed0bc3f3c1e350f1c4cc2c0a634f676bedcc7cc116379f619abec6', 'allowed': True, 'vsize': 192, 'fees': {'base': 1e-05}}]
Sweet. 🍭
Note: Often the signature obtained from openssl
will not be a “canonical” signature and thus marked as invalid for the Bitcoin transaction. See BIP62 for details. If this happens, testmempoolaccept
may give you the following error: mandatory-script-verify-flag-failed (Non-canonical signature: S value is unnecessarily high)
. You might have to try re-creating the signature until you get a proper s-value.
Signing with python-ecdsa
As an alternative (and potentially less secure) way to sign the transaction we can use python-ecdsa
. You can install it from pypi with the following.
pip install ecdsa
We’ll start from the raw transaction we created earlier with bitcoin-cli
raw_tx = bytes.fromhex("02000000012797bc54ea5dc356104c9e4be0e57c7dfc4bde12ec26983b2d4f55e1e88ff1ab0000000000fdffffff01d0b30d00000000001976a914f5326ca3988fd949f3b7699922caf3eadc86e71488ac00000000")
And, of course we insert the previous outpoint’s scriptPubKey for signing, as well as append the SIGHASH_ALL
bytes.
sig_data = bytes.fromhex("02000000012797bc54ea5dc356104c9e4be0e57c7dfc4bde12ec26983b2d4f55e1e88ff1ab000000001976a914ac3cb4fc6ee6663e697c743aad0d89fd8eb4c15f88acfdffffff01d0b30d00000000001976a914f5326ca3988fd949f3b7699922caf3eadc86e71488ac0000000001000000")
From here, like before, we take the double sha256 hash and sign it. We can use python’s hashlib for taking sha256, and we’ll use python-ecdsa
to sign.
>>> import hashlib
>>> import ecdsa
>>> sig_data = hashlib.sha256(hashlib.sha256(sig_data).digest()).digest()
>>> with open("secret.pem", "rb") as pem_file:
... pem = pem_file.read()
...
>>> sk = ecdsa.SigningKey.from_pem(pem)
>>> sig = sk.sign_digest(sig_data, sigencode=ecdsa.util.sigencode_der)
>>> sig.hex()
'3044022064ca8305562c788a7f7b17d68a2c0141f7b62914e7f2950fe043f01bfbbb99e902201179d318d6332b871827b8fb310b013f19401c6ea74adbde990e466c736285a7'
Again, we take this signature and the previous outpoint’s pubkey to form the scriptSig
>>> SIGHASH_ALL = 0x01
>>> pubkey = bytes.fromhex("02726b45a5b1b506015dc926630b2627454d635d87eeb72bb7d5476d545d6769f9")
>>> p2pkh_script_sig = (len(sig) + 1).to_bytes(1, "little") + sig + SIGHASH_ALL.to_bytes(1, "little") + len(pubkey).to_bytes(1, "little") + pubkey
>>> p2pkh_script_sig.hex()
'473044022064ca8305562c788a7f7b17d68a2c0141f7b62914e7f2950fe043f01bfbbb99e902201179d318d6332b871827b8fb310b013f19401c6ea74adbde990e466c736285a7012102726b45a5b1b506015dc926630b2627454d635d87eeb72bb7d5476d545d6769f9'
Now finally, we replace this scriptSig in the original transaction (including the OP_PUSHDATA of hex 6a
for its length).
02000000012797bc54ea5dc356104c9e4be0e57c7dfc4bde12ec26983b2d4f55e1e88ff1ab000000006a473044022064ca8305562c788a7f7b17d68a2c0141f7b62914e7f2950fe043f01bfbbb99e902201179d318d6332b871827b8fb310b013f19401c6ea74adbde990e466c736285a7012102726b45a5b1b506015dc926630b2627454d635d87eeb72bb7d5476d545d6769f9fdffffff01d0b30d00000000001976a914f5326ca3988fd949f3b7699922caf3eadc86e71488ac00000000
And test it with bitcoin-cli
’s testmempoolaccept
bitcoin-cli testmempoolaccept '["02000000012797bc54ea5dc356104c9e4be0e57c7dfc4bde12ec26983b2d4f55e1e88ff1ab000000006a473044022064ca8305562c788a7f7b17d68a2c0141f7b62914e7f2950fe043f01bfbbb99e902201179d318d6332b871827b8fb310b013f19401c6ea74adbde990e466c736285a7012102726b45a5b1b506015dc926630b2627454d635d87eeb72bb7d5476d545d6769f9fdffffff01d0b30d00000000001976a914f5326ca3988fd949f3b7699922caf3eadc86e71488ac00000000"]'
Output:
[{'txid': 'e110d9ab2c5af39b35724f1a9de28379f3c4dd0c0ceb96c44e87787351b567b4', 'wtxid': 'e110d9ab2c5af39b35724f1a9de28379f3c4dd0c0ceb96c44e87787351b567b4', 'allowed': True, 'vsize': 191, 'fees': {'base': 1e-05}}]
Nice. 🤓
Conclusion
And there we have it. Just by using some open source tooling we can create keys, generate Bitcoin addresses, create raw transactions, and sign them for spenditure. We got somewhat lucky with openssl
and python-ecdsa
by generating canonical signatures on the first try. If we hadn’t, we could have simply re-tried until achieveing one.
Final thoughts on python-ecdsa
security
You may ask why I chose to show the use of python-ecdsa
if it is potentially less secure. Honestly, I’m not sure 😅. I sort of wanted to document how to do it with this Python library anyways 🤷♂️. Also, candidly, I don’t completely understand the security risks in python-ecdsa
’s README. I think I understand what a side-channel attack is, but how much of a concern is it really? Does openssl
provide security against these attacks? What about other common Bitcoin wallets and libraries? Furthermore, python-ecdsa
’s README proclaims that any pure Python implementation would be vulnerable since “Python does not provide side channel secure primitives”. That seems like a bold accusation. Is it true? At the end of the day, I don’t know, and I suppose I will have to the leave it to the cryptographic experts for now. If side-channel secure programming is indeed “impossible” in pure Python, I would hope there’d be some effort to fix or improve that. Either way, I’m hoping to stir some conversation from the community with this blog.
Actually sending the transaction on Bitcoin Testnet
Finally, I’ll go ahead and actually send the transaction on Testnet (using the transaction signed via openssl
shown above)
bitcoin-cli sendrawtransaction "02000000012797bc54ea5dc356104c9e4be0e57c7dfc4bde12ec26983b2d4f55e1e88ff1ab000000006b483045022100df741c554ee34ab636a213956abf85fd4d05143026eb7b5f07ba56ac8978687902207c7e70f5790c6146599dd7c035111b93f131d95591b6030c80b1010d2e047ed3012102726b45a5b1b506015dc926630b2627454d635d87eeb72bb7d5476d545d6769f9fdffffff01d0b30d00000000001976a914f5326ca3988fd949f3b7699922caf3eadc86e71488ac00000000"
Output:
766a1be97eed0bc3f3c1e350f1c4cc2c0a634f676bedcc7cc116379f619abec6
I hope y’all enjoyed this blog. Stay tuned for more content! ✌️